Initial reader-api backend extracted from reader

This commit is contained in:
2026-03-24 13:55:10 +07:00
parent 56f8f5ccfc
commit 24f070d14e
69 changed files with 12167 additions and 1 deletions
+2
View File
@@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto
+43
View File
@@ -0,0 +1,43 @@
name: Build and Push Docker Image
on:
push:
jobs:
docker:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: true
tags: |
fevirtus/reader:latest
ghcr.io/fevirtus/reader:latest
cache-from: type=gha
cache-to: type=gha,mode=max
+12
View File
@@ -0,0 +1,12 @@
# v0 runtime files
__v0_runtime_loader.js
__v0_devtools.tsx
__v0_jsx-dev-runtime.ts
# Common ignores
node_modules/
.next/
.env*.local
.DS_Store
.env
test-ebook/
+36
View File
@@ -0,0 +1,36 @@
# Stage 1: Dependencies
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable pnpm && pnpm install --frozen-lockfile
# Stage 2: Builder
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Update Alpine and install ssl for Prisma
RUN apk add --no-cache openssl
# Tạo prisma client
RUN npx prisma generate
# Chạy build
RUN corepack enable pnpm && pnpm run build
# Stage 3: Runner
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
# Install openssl for Prisma runtime
RUN apk add --no-cache openssl
COPY --from=builder /app/public ./public
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
CMD ["node", "server.js"]
+179 -1
View File
@@ -1,2 +1,180 @@
# reader-api
Shared backend API for reader web and reader-app mobile clients
Đây là backend API dùng chung cho cả web app `reader` và mobile app `reader-app`.
## 🚀 Tính năng nổi bật
- **Xác thực & Phân quyền**: Đăng nhập bằng Google Authentication (NextAuth). Hỗ trợ phân quyền người dùng (USER, MOD, ADMIN).
- **Quản lý nội dung (Dành cho MOD/ADMIN)**: Dashboard quản lý truyện, tải lên chương mới, quản lý trạng thái truyện (Đang ra, Hoàn thành, Tạm ngưng).
- **Trải nghiệm đọc**: Khám phá truyện theo thể loại, tìm kiếm truyện, đọc chương truyện với hiệu suất cao (nội dung lưu ở MongoDB).
- **Tương tác người dùng**: Tính năng tủ sách (bookmark) giúp lưu lại tiến độ đọc, hỗ trợ bình luận ở truyện và từng chương.
## 🛠 Tech Stack
- **Framework**: [Next.js](https://nextjs.org/) (App Router), React 19
- **Styling**: [TailwindCSS v4](https://tailwindcss.com/) & [Radix UI](https://www.radix-ui.com/) (shadcn/ui)
- **Database Hybrid**:
- **PostgreSQL**: Lưu trữ dữ liệu cấu trúc (Tài khoản, Truyện, Thể loại, Bình luận, Tủ sách) thông qua **Prisma ORM**.
- **MongoDB**: Lưu trữ nội dung lớn (Chương truyện) thông qua **Mongoose**.
- **Auth**: [NextAuth.js](https://next-auth.js.org/)
---
## 💻 Hướng dẫn chạy Local (Phát triển)
### 1. Yêu cầu cài đặt
- [Node.js](https://nodejs.org/) (Khuyến nghị bản LTS)
- [pnpm](https://pnpm.io/) (Tool quản lý package)
- Database: PostgreSQL và MongoDB đang chạy cục bộ hoặc trên máy chủ.
### 2. Cấu hình môi trường
Tạo file `.env` ở thư mục gốc dựa trên `.env.example` (nếu có) hoặc điền các thông tin sau:
```env
# URL kết nối PostgreSQL
DATABASE_URL="postgresql://user:password@localhost:5432/reader?schema=public"
# URL kết nối MongoDB
MONGODB_URI="mongodb://user:password@localhost:27017/reader?authSource=admin"
# Cấu hình NextAuth
NEXTAUTH_SECRET="your-super-secret-key"
NEXTAUTH_URL="http://localhost:3000"
# Cấu hình Google Login
GOOGLE_CLIENT_ID="your_google_client_id"
GOOGLE_CLIENT_SECRET="your_google_client_secret"
# AI Tool cho MOD (LLM + web search)
OPENAI_API_KEY="your_openai_api_key"
# Tùy chọn, mặc định: gpt-4o-mini-search-preview
OPENAI_WEB_MODEL="gpt-4o-mini-search-preview"
# Cloudflare R2 (lưu ảnh bìa)
R2_ACCOUNT_ID="your_cloudflare_account_id"
R2_ACCESS_KEY_ID="your_r2_access_key_id"
R2_SECRET_ACCESS_KEY="your_r2_secret_access_key"
R2_BUCKET_NAME="your_r2_bucket_name"
R2_PUBLIC_BASE_URL="https://your-public-r2-domain"
```
### 3. Cài đặt dependencies và khởi tạo DB
```bash
# Cài đặt các gói thư viện
pnpm install
# Đồng bộ schema xuống PostgreSQL và generate Prisma client
npx prisma db push
# hoặc (nếu muốn dùng migrate)
# npx prisma migrate dev
# Generate thư viện Prisma
npx prisma generate
```
### 4. Chạy môi trường phát triển
```bash
pnpm dev
```
API chạy mặc định ở [http://localhost:3001](http://localhost:3001).
Ví dụ endpoint: [http://localhost:3001/api/novels/browse](http://localhost:3001/api/novels/browse).
---
## 🏗 Hướng dẫn Build
Để build project cho môi trường production:
```bash
# Đảm bảo Prisma Client đã được generate
npx prisma generate
# Chạy lệnh build của Next.js
pnpm build
```
Sau khi build xong, bạn có thể khởi chạy server production bằng:
```bash
pnpm start
```
---
## 🐳 Triển khai dưới dạng Docker
Bạn có thể dễ dàng triển khai ứng dụng bằng nền tảng Docker. Dưới đây là cách đóng gói và chạy thông qua `docker-compose`.
### 1. Tạo file `Dockerfile`
Tạo file `Dockerfile` ở thư mục gốc của dự án với cấu hình multi-stage build để tối ưu dung lượng:
```dockerfile
# Stage 1: Dependencies
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable pnpm && pnpm install --frozen-lockfile
# Stage 2: Builder
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Tạo prisma client
RUN npx prisma generate
# Chạy build
RUN corepack enable pnpm && pnpm build
# Stage 3: Runner
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
ENV PORT=3000
CMD ["node", "server.js"]
```
*(Lưu ý: Để build mục `standalone` hoạt động, bạn cần bổ sung `output: 'standalone'` trong file `next.config.mjs`)*
### 2. Tạo file `docker-compose.yml`
Sử dụng Docker Compose để chạy ứng dụng (giả sử Database của bạn được host riêng hoặc bạn có thể thêm service DB vào file này):
```yaml
version: '3.8'
services:
web:
build:
context: .
dockerfile: Dockerfile
image: reader-web:latest
container_name: reader-app
ports:
- "3000:3000"
env_file:
- .env
restart: unless-stopped
```
### 3. Khởi chạy bằng Docker
Chạy lệnh sau để build image và start container:
```bash
# Build và chạy ngầm (detached mode)
docker-compose up -d --build
```
Để xem log của container:
```bash
docker-compose logs -f web
```
Dừng và xóa container:
```bash
docker-compose down
```
+6
View File
@@ -0,0 +1,6 @@
import NextAuth from "next-auth"
import { authOptions } from "@/lib/auth"
const handler = NextAuth(authOptions)
export { handler as GET, handler as POST }
+95
View File
@@ -0,0 +1,95 @@
import { NextResponse } from "next/server"
import { prisma } from "@/lib/prisma"
import { OAuth2Client } from "google-auth-library"
import { SignJWT, importPKCS8, generateKeyPair } from "jose"
import * as crypto from "crypto"
const googleClient = new OAuth2Client(process.env.GOOGLE_CLIENT_ID)
function generateTokens(userId: string) {
const secret = process.env.MOBILE_JWT_SECRET || process.env.NEXTAUTH_SECRET || ""
const key = crypto.createHmac("sha256", secret)
const payload = Buffer.from(JSON.stringify({ sub: userId, iat: Math.floor(Date.now() / 1000) })).toString("base64url")
const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url")
const sig = key.update(`${header}.${payload}`).digest("base64url")
const accessToken = `${header}.${payload}.${sig}`
// refresh token: random 40-byte hex, stored hashed in DB if needed; for now return raw
const refreshToken = crypto.randomBytes(40).toString("hex")
return { accessToken, refreshToken }
}
export async function POST(req: Request) {
try {
const body = await req.json()
const { googleIdToken } = body
if (!googleIdToken || typeof googleIdToken !== "string") {
return NextResponse.json({ error: "googleIdToken is required" }, { status: 400 })
}
// Verify the Google ID token
let ticket
try {
ticket = await googleClient.verifyIdToken({
idToken: googleIdToken,
audience: process.env.GOOGLE_CLIENT_ID,
})
} catch {
return NextResponse.json({ error: "Invalid Google token" }, { status: 401 })
}
const payload = ticket.getPayload()
if (!payload?.email) {
return NextResponse.json({ error: "Unable to extract email from token" }, { status: 401 })
}
const { email, name, picture, sub: googleSub } = payload
// Upsert user — match NextAuth behaviour
let user = await prisma.user.findUnique({ where: { email } })
if (!user) {
user = await prisma.user.create({
data: {
email,
name: name ?? null,
image: picture ?? null,
emailVerified: new Date(),
},
})
}
// Upsert Google Account link
const existingAccount = await prisma.account.findUnique({
where: { provider_providerAccountId: { provider: "google", providerAccountId: googleSub } },
})
if (!existingAccount) {
await prisma.account.create({
data: {
userId: user.id,
type: "oauth",
provider: "google",
providerAccountId: googleSub,
},
})
}
const { accessToken, refreshToken } = generateTokens(user.id)
return NextResponse.json({
accessToken,
refreshToken,
expiresIn: 3600,
user: {
id: user.id,
email: user.email,
name: user.name,
image: user.image,
role: user.role,
},
})
} catch (error) {
console.error("Mobile login error:", error)
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 })
}
}
+50
View File
@@ -0,0 +1,50 @@
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<{ chapterId: string }> }
) {
try {
const { chapterId } = await params
await connectToMongoDB()
const chapter = await Chapter.findById(chapterId).lean()
if (!chapter) {
return NextResponse.json({ error: "Chapter not found" }, { status: 404 })
}
// Fetch prev/next chapter numbers for navigation
const [prevChapter, nextChapter] = await Promise.all([
Chapter.findOne({ novelId: (chapter as any).novelId, number: (chapter as any).number - 1 })
.select("_id number")
.lean(),
Chapter.findOne({ novelId: (chapter as any).novelId, number: (chapter as any).number + 1 })
.select("_id number")
.lean(),
])
return NextResponse.json({
id: (chapter as any)._id.toString(),
novelId: (chapter as any).novelId,
number: (chapter as any).number,
title: (chapter as any).title,
content: (chapter as any).content,
views: (chapter as any).views,
volumeNumber: (chapter as any).volumeNumber ?? null,
volumeTitle: (chapter as any).volumeTitle ?? null,
volumeChapterNumber: (chapter as any).volumeChapterNumber ?? null,
createdAt: ((chapter as any).createdAt as Date).toISOString(),
prevChapterId: prevChapter ? (prevChapter as any)._id.toString() : null,
prevChapterNumber: prevChapter ? (prevChapter as any).number : null,
nextChapterId: nextChapter ? (nextChapter as any)._id.toString() : null,
nextChapterNumber: nextChapter ? (nextChapter as any).number : null,
})
} catch (error) {
console.error("Chapter content error:", error)
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 })
}
}
+30
View File
@@ -0,0 +1,30 @@
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 })
}
}
+32
View File
@@ -0,0 +1,32 @@
import { NextResponse } from "next/server"
import { prisma } from "@/lib/prisma"
export async function GET() {
try {
const genres = await prisma.genre.findMany({
orderBy: { name: "asc" },
select: {
id: true,
name: true,
slug: true,
description: true,
icon: true,
_count: { select: { novels: true } },
},
})
return NextResponse.json(
genres.map((g) => ({
id: g.id,
name: g.name,
slug: g.slug,
description: g.description,
icon: g.icon,
novelCount: g._count.novels,
}))
)
} catch (error) {
console.error("Genres list error:", error)
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 })
}
}
@@ -0,0 +1,174 @@
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 })
}
}
+689
View File
@@ -0,0 +1,689 @@
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<T>(url: string, init: RequestInit, timeoutMs = 25000): Promise<T> {
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<ProviderResult | null> {
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<GeminiResponse>(
`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<string[]> {
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<OpenRouterModelsResponse>(
"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<ProviderResult | null> {
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<ChatResponse>(
"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<ProviderResult | null> {
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<ChatResponse>(
"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",
})
}
@@ -0,0 +1,79 @@
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 })
}
@@ -0,0 +1,59 @@
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 })
}
}
+53
View File
@@ -0,0 +1,53 @@
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 })
}
}
+63
View File
@@ -0,0 +1,63 @@
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 })
}
}
+121
View File
@@ -0,0 +1,121 @@
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 })
}
}
+77
View File
@@ -0,0 +1,77 @@
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 })
}
}
+189
View File
@@ -0,0 +1,189 @@
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 })
}
}
+262
View File
@@ -0,0 +1,262 @@
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<string, number>()
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<typeof item> => 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<typeof item> => 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 })
}
}
File diff suppressed because it is too large Load Diff
+200
View File
@@ -0,0 +1,200 @@
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 })
}
}
+71
View File
@@ -0,0 +1,71 @@
import { NextResponse } from "next/server"
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { slugify } from "@/lib/utils"
// Get all genres
export async function GET() {
try {
const genres = await prisma.genre.findMany({
orderBy: { name: "asc" }
})
return NextResponse.json(genres)
} catch (error) {
return NextResponse.json({ error: "Failed to fetch genres" }, { status: 500 })
}
}
// Admins/Mods can add new genres
export async function POST(req: Request) {
const session = await getServerSession(authOptions)
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
try {
const data = await req.json()
const { name, description } = data
if (!name) {
return NextResponse.json({ error: "Genre name is required" }, { status: 400 })
}
const slug = slugify(name)
const newGenre = await prisma.genre.create({
data: { name, slug, description }
})
return NextResponse.json(newGenre, { status: 201 })
} catch (error: any) {
if (error.code === 'P2002') {
return NextResponse.json({ error: "Thể loại này đã tồn tại" }, { status: 400 })
}
return NextResponse.json({ error: "Failed to create genre" }, { status: 500 })
}
}
export async function DELETE(req: Request) {
const session = await getServerSession(authOptions)
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
try {
const url = new URL(req.url)
const id = url.searchParams.get("id")
if (!id) {
return NextResponse.json({ error: "Thiếu ID thể loại" }, { status: 400 })
}
await prisma.genre.delete({
where: { id }
})
return NextResponse.json({ message: "Đã xóa thể loại thành công" })
} catch (error) {
return NextResponse.json({ error: "Lỗi khi xóa thể loại" }, { status: 500 })
}
}
+45
View File
@@ -0,0 +1,45 @@
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 })
}
}
@@ -0,0 +1,72 @@
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 })
}
}
+70
View File
@@ -0,0 +1,70 @@
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 })
}
}
+289
View File
@@ -0,0 +1,289 @@
import { NextResponse } from "next/server"
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
type MissingKey = "author" | "cover" | "description" | "genres"
const ALL_MISSING_KEYS: MissingKey[] = ["author", "cover", "description", "genres"]
function getScopeWhere(session: { user: { role: string; id: string } }) {
if (session.user.role === "ADMIN") {
return {}
}
return {
OR: [
{ uploaderId: session.user.id },
{ uploaderId: null },
],
}
}
function parseMissingKeys(raw: string | null): MissingKey[] {
if (!raw || !raw.trim()) return ALL_MISSING_KEYS
const parsed = raw
.split(",")
.map((item) => item.trim().toLowerCase())
.filter((item): item is MissingKey => ALL_MISSING_KEYS.includes(item as MissingKey))
if (parsed.length === 0) return ALL_MISSING_KEYS
return Array.from(new Set(parsed))
}
function buildMissingWhereForKey(key: MissingKey) {
switch (key) {
case "author":
return { authorName: { equals: "" } }
case "cover":
return {
OR: [
{ coverUrl: null },
{ coverUrl: { equals: "" } },
],
}
case "description":
return { description: { equals: "" } }
case "genres":
return { genres: { none: {} } }
default:
return {}
}
}
function computeMissingStatus(novel: {
authorName: string
coverUrl: string | null
description: string
genres: Array<{ genre: { id: string; name: string } }>
}) {
const authorMissing = novel.authorName.trim().length === 0
const coverMissing = !novel.coverUrl || novel.coverUrl.trim().length === 0
const descriptionMissing = novel.description.trim().length === 0
const genresMissing = novel.genres.length === 0
return {
author: authorMissing,
cover: coverMissing,
description: descriptionMissing,
genres: genresMissing,
}
}
function hasSelectedMissing(missingStatus: Record<MissingKey, boolean>, selected: MissingKey[]) {
return selected.some((key) => missingStatus[key])
}
export async function GET(req: Request) {
const session = await getServerSession(authOptions)
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
try {
const url = new URL(req.url)
const q = (url.searchParams.get("q") || "").trim()
const selectedMissing = parseMissingKeys(url.searchParams.get("missing"))
const andWhere: any[] = [getScopeWhere(session)]
if (q) {
andWhere.push({
OR: [
{ title: { contains: q, mode: "insensitive" } },
{ slug: { contains: q, mode: "insensitive" } },
{ authorName: { contains: q, mode: "insensitive" } },
{ series: { name: { contains: q, mode: "insensitive" } } },
],
})
}
if (selectedMissing.length > 0) {
andWhere.push({
OR: selectedMissing.map((key) => buildMissingWhereForKey(key)),
})
}
const novels = await (prisma as any).novel.findMany({
where: { AND: andWhere },
orderBy: [{ updatedAt: "desc" }],
take: 600,
select: {
id: true,
title: true,
slug: true,
authorName: true,
coverUrl: true,
description: true,
totalChapters: true,
updatedAt: true,
series: {
select: {
id: true,
name: true,
slug: true,
},
},
genres: {
select: {
genre: {
select: {
id: true,
name: true,
},
},
},
},
},
})
const items = novels
.map((novel: any) => {
const missing = computeMissingStatus(novel)
return {
id: novel.id,
title: novel.title,
slug: novel.slug,
authorName: novel.authorName,
coverUrl: novel.coverUrl,
description: novel.description,
totalChapters: novel.totalChapters,
updatedAt: novel.updatedAt,
series: novel.series,
genres: novel.genres.map((item: any) => item.genre),
missing,
}
})
.filter((item: any) => hasSelectedMissing(item.missing, selectedMissing))
return NextResponse.json({
items,
total: items.length,
})
} catch (error) {
console.error("Failed to fetch novels with missing fields", error)
return NextResponse.json({ error: "Failed to fetch missing-field novels" }, { status: 500 })
}
}
export async function PATCH(req: Request) {
const session = await getServerSession(authOptions)
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
try {
const body = await req.json()
const updates = Array.isArray(body?.updates) ? body.updates : []
if (updates.length === 0) {
return NextResponse.json({ error: "Thiếu danh sách cập nhật" }, { status: 400 })
}
if (updates.length > 200) {
return NextResponse.json({ error: "Chỉ hỗ trợ tối đa 200 bản ghi mỗi lần" }, { status: 400 })
}
const ids = updates
.map((item: any) => (typeof item?.id === "string" ? item.id : ""))
.filter(Boolean)
if (ids.length === 0) {
return NextResponse.json({ error: "Danh sách ID không hợp lệ" }, { status: 400 })
}
const allowedRows = await (prisma as any).novel.findMany({
where: {
AND: [
getScopeWhere(session),
{ id: { in: ids } },
],
},
select: { id: true },
})
const allowedSet = new Set(allowedRows.map((row: any) => row.id))
let updatedCount = 0
let skippedCount = 0
const failures: Array<{ id: string; error: string }> = []
for (const raw of updates) {
const id = typeof raw?.id === "string" ? raw.id : ""
if (!id) {
skippedCount += 1
continue
}
if (!allowedSet.has(id)) {
failures.push({ id, error: "Không có quyền cập nhật truyện này" })
continue
}
const data: Record<string, any> = {}
if (typeof raw.authorName === "string") {
data.authorName = raw.authorName.trim()
}
if (typeof raw.coverUrl === "string") {
const normalizedCover = raw.coverUrl.trim()
data.coverUrl = normalizedCover.length > 0 ? normalizedCover : null
} else if (raw.coverUrl === null) {
data.coverUrl = null
}
if (typeof raw.description === "string") {
data.description = raw.description.trim()
}
const hasGenreUpdate = Array.isArray(raw.genreIds)
const genreIds: string[] = hasGenreUpdate
? Array.from(new Set((raw.genreIds as unknown[]).filter((item): item is string => typeof item === "string" && item.trim().length > 0)))
: []
if (Object.keys(data).length === 0 && !hasGenreUpdate) {
skippedCount += 1
continue
}
try {
await prisma.$transaction(async (tx) => {
if (Object.keys(data).length > 0) {
await (tx as any).novel.update({
where: { id },
data,
})
}
if (hasGenreUpdate) {
await (tx as any).novelGenre.deleteMany({ where: { novelId: id } })
if (genreIds.length > 0) {
await (tx as any).novelGenre.createMany({
data: genreIds.map((genreId) => ({ novelId: id, genreId })),
skipDuplicates: true,
})
}
}
})
updatedCount += 1
} catch (error: any) {
failures.push({ id, error: error?.message || "Cập nhật thất bại" })
}
}
return NextResponse.json({
updatedCount,
skippedCount,
failureCount: failures.length,
failures,
})
} catch (error) {
console.error("Failed to patch missing-field novels", error)
return NextResponse.json({ error: "Failed to update novels" }, { status: 500 })
}
}
+320
View File
@@ -0,0 +1,320 @@
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<string | null> {
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<string, unknown> = {}
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<string, unknown> = {}
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<string, unknown> = {
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 })
}
}
+39
View File
@@ -0,0 +1,39 @@
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 })
}
}
+67
View File
@@ -0,0 +1,67 @@
import { NextResponse } from "next/server"
import { prisma } from "@/lib/prisma"
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
// Support both id and slug
const novel = await prisma.novel.findFirst({
where: { OR: [{ id }, { slug: id }] },
select: {
id: true,
title: true,
slug: true,
originalTitle: true,
authorName: true,
originalAuthorName: true,
description: true,
coverUrl: true,
coverColor: true,
status: true,
totalChapters: true,
views: true,
rating: true,
ratingCount: true,
bookmarkCount: true,
seriesId: true,
series: {
select: {
id: true,
name: true,
slug: true,
novels: {
select: {
id: true,
title: true,
slug: true,
totalChapters: true,
status: true,
coverUrl: true,
},
orderBy: { title: "asc" },
},
},
},
genres: { select: { genre: { select: { id: true, name: true, slug: true } } } },
createdAt: true,
updatedAt: true,
},
})
if (!novel) {
return NextResponse.json({ error: "Novel not found" }, { status: 404 })
}
return NextResponse.json({
...novel,
genres: novel.genres.map((g) => g.genre),
})
} catch (error) {
console.error("Novel detail error:", error)
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 })
}
}
+115
View File
@@ -0,0 +1,115 @@
import { NextResponse } from "next/server"
import { prisma } from "@/lib/prisma"
import connectToMongoDB from "@/lib/mongoose"
import { Chapter } from "@/lib/models/chapter"
type SortKey = "latest" | "popular" | "rating" | "name"
export async function GET(req: Request) {
try {
const { searchParams } = new URL(req.url)
const q = searchParams.get("q")?.trim() || ""
const genre = searchParams.get("genre") || ""
const status = searchParams.get("status") || ""
const sort: SortKey = (searchParams.get("sort") as SortKey) || "latest"
const page = Math.max(1, parseInt(searchParams.get("page") || "1", 10))
const limit = Math.min(100, Math.max(1, parseInt(searchParams.get("limit") || "20", 10)))
const skip = (page - 1) * limit
// Build where clause
const where: Record<string, any> = {}
if (status) where.status = status
if (genre) {
where.genres = { some: { genre: { slug: genre } } }
}
if (q) {
where.OR = [
{ title: { contains: q, mode: "insensitive" } },
{ originalTitle: { contains: q, mode: "insensitive" } },
{ authorName: { contains: q, mode: "insensitive" } },
{ originalAuthorName: { contains: q, mode: "insensitive" } },
{ series: { name: { contains: q, mode: "insensitive" } } },
]
}
// Build orderBy
const orderBy: Record<string, any> =
sort === "popular"
? { views: "desc" }
: sort === "rating"
? { rating: "desc" }
: sort === "name"
? { title: "asc" }
: { updatedAt: "desc" }
const [novels, totalCount] = await Promise.all([
prisma.novel.findMany({
where,
orderBy,
skip,
take: limit,
select: {
id: true,
title: true,
slug: true,
originalTitle: true,
authorName: true,
coverUrl: true,
coverColor: true,
status: true,
totalChapters: true,
views: true,
rating: true,
ratingCount: true,
bookmarkCount: true,
seriesId: true,
series: { select: { id: true, name: true, slug: true } },
genres: { select: { genre: { select: { id: true, name: true, slug: true } } } },
updatedAt: true,
},
}),
prisma.novel.count({ where }),
])
// Attach latest chapter info
await connectToMongoDB()
const novelIds = novels.map((n) => n.id)
const latestChapters = await Chapter.aggregate([
{ $match: { novelId: { $in: novelIds } } },
{ $sort: { novelId: 1, number: -1 } },
{
$group: {
_id: "$novelId",
latestChapterNumber: { $first: "$number" },
latestChapterTitle: { $first: "$title" },
latestChapterAt: { $first: "$createdAt" },
},
},
])
const chapterMap = Object.fromEntries(
latestChapters.map((c) => [c._id, c])
)
const items = novels.map((n) => ({
...n,
genres: n.genres.map((g) => g.genre),
latestChapter: chapterMap[n.id]
? {
number: chapterMap[n.id].latestChapterNumber,
title: chapterMap[n.id].latestChapterTitle,
createdAt: chapterMap[n.id].latestChapterAt,
}
: null,
}))
return NextResponse.json({
items,
totalCount,
totalPages: Math.ceil(totalCount / limit),
currentPage: page,
})
} catch (error) {
console.error("Browse novels error:", error)
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 })
}
}
+52
View File
@@ -0,0 +1,52 @@
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 }
)
}
}
+95
View File
@@ -0,0 +1,95 @@
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 }> }) {
try {
const { id: novelId } = await params
const { searchParams } = new URL(req.url)
const chapterId = searchParams.get("chapterId") || undefined
const page = Math.max(1, parseInt(searchParams.get("page") || "1", 10))
const limit = Math.min(50, Math.max(1, parseInt(searchParams.get("limit") || "20", 10)))
const skip = (page - 1) * limit
const where: Record<string, any> = { novelId }
if (chapterId) {
where.chapterId = chapterId
} else {
where.chapterId = null
}
const [comments, totalCount] = await Promise.all([
prisma.comment.findMany({
where,
orderBy: { createdAt: "desc" },
skip,
take: limit,
include: { user: { select: { id: true, name: true, image: true } } },
}),
prisma.comment.count({ where }),
])
return NextResponse.json({
comments: comments.map((c) => ({
id: c.id,
userId: c.userId,
username: c.user.name || "User",
avatarUrl: c.user.image || null,
novelId: c.novelId,
chapterId: c.chapterId,
content: c.content,
createdAt: c.createdAt.toISOString(),
})),
totalCount,
totalPages: Math.ceil(totalCount / limit),
currentPage: page,
})
} catch (error) {
console.error("GET Comments Error", error)
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 })
}
}
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 })
}
}
+41
View File
@@ -0,0 +1,41 @@
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 })
}
// Atomic increment using raw SQL to avoid race conditions
const updatedNovel = await prisma.$queryRaw<{ rating: number; ratingCount: number }[]>`
UPDATE "Novel"
SET "ratingCount" = "ratingCount" + 1,
"rating" = (("rating" * "ratingCount") + ${score}) / ("ratingCount" + 1)
WHERE id = ${id}
RETURNING rating, "ratingCount"
`.then((rows) => rows[0])
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 })
}
}
+42
View File
@@ -0,0 +1,42 @@
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 })
}
}
+181
View File
@@ -0,0 +1,181 @@
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 })
}
}
+170
View File
@@ -0,0 +1,170 @@
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<typeof item> => 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 })
}
}
+59
View File
@@ -0,0 +1,59 @@
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 })
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
+20
View File
@@ -0,0 +1,20 @@
version: '3.8'
services:
web:
image: fevirtus/reader:v0.0.1
container_name: reader-web
ports:
- "3003:3000"
environment:
# KHÔNG SỬ DỤNG DẤU NGOẶC KÉP "" TRONG DOCKER COMPOSE
- DATABASE_URL=postgresql://reader:reader%40123@master-02:5432/reader?schema=public
- MONGODB_URI=mongodb://root:virtus%40123@master-02:27017/reader?authSource=admin
- NEXTAUTH_SECRET=your-super-secret-key
# Sửa thành domain name thực tế bạn đang truy cập
- NEXTAUTH_URL=http://master-02:3003
- GOOGLE_CLIENT_ID=752734667309-khhufui27coorhmk8gh15epbpbeerg25.apps.googleusercontent.com
- GOOGLE_CLIENT_SECRET=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
volumes:
- ./uploads:/app/public/uploads
restart: unless-stopped
+38
View File
@@ -0,0 +1,38 @@
"use client"
import { SessionProvider, useSession, signIn, signOut } from "next-auth/react"
import { useMemo, type ReactNode } from "react"
import type { User } from "./types"
export function AuthProvider({ children }: { children: ReactNode }) {
return <SessionProvider>{children}</SessionProvider>
}
// Giữ nguyên custom hook `useAuth` để tương thích ngược với UI components hiện tại
export function useAuth() {
const { data: session, status } = useSession()
const isLoading = status === "loading"
// Chuyển đổi session user thành format User của project
const sessionUser = session?.user
const user: User | null = useMemo(() => {
if (!sessionUser) return null
return {
id: (sessionUser as any).id || "",
username: (sessionUser as any).name || "Người dùng",
email: (sessionUser as any).email || "",
avatarUrl: (sessionUser as any).image || "",
avatarColor: "bg-blue-500", // Mặc định
role: (sessionUser as any).role || "USER",
createdAt: new Date().toISOString().split("T")[0],
}
}, [sessionUser])
const loginWithGoogle = () => signIn("google", { callbackUrl: "/" })
const logout = () => signOut({ callbackUrl: "/" })
return { user, isLoading, loginWithGoogle, logout }
}
+35
View File
@@ -0,0 +1,35 @@
import { NextAuthOptions } from "next-auth"
import GoogleProvider from "next-auth/providers/google"
import { PrismaAdapter } from "@auth/prisma-adapter"
import { prisma } from "./prisma"
export const authOptions: NextAuthOptions = {
adapter: PrismaAdapter(prisma) as any, // ép kiểu vì type mismatch nhỏ
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID || "demo-id",
clientSecret: process.env.GOOGLE_CLIENT_SECRET || "demo-secret",
}),
],
session: {
// Để giữ NextAuth dùng JWT thay vì lưu phiên vào DB nếu thích, nhưng khi dùng PrismaAdapter, mặc định nó dùng DB strategy.
// strategy: "jwt",
},
callbacks: {
async session({ session, user }) {
if (session.user) {
// Lấy role từ DB gán vào session
const dbUser = await prisma.user.findUnique({
where: { email: session.user.email as string },
select: { role: true, id: true },
})
session.user.id = dbUser?.id || user.id
session.user.role = dbUser?.role || "USER"
}
return session
},
},
// Tuân thủ bảo mật NextAuth
secret: process.env.NEXTAUTH_SECRET,
}
+121
View File
@@ -0,0 +1,121 @@
"use client"
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from "react"
import type { Bookmark } from "./types"
import { useAuth } from "./auth-context"
interface BookmarkContextType {
bookmarks: Bookmark[]
isBookmarked: (novelId: string) => boolean
toggleBookmark: (novelId: string) => Promise<void>
updateProgress: (novelId: string, chapterId: string, chapterNumber: number) => Promise<void>
getProgress: (novelId: string) => Bookmark | undefined
}
const BookmarkContext = createContext<BookmarkContextType | undefined>(undefined)
export function BookmarkProvider({ children }: { children: ReactNode }) {
const { user } = useAuth()
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
const fetchBookmarks = useCallback(async () => {
if (!user) {
setBookmarks([])
return
}
try {
const res = await fetch("/api/user/bookmarks")
if (!res.ok) return
const data = await res.json()
setBookmarks(Array.isArray(data) ? data : [])
} catch (e) {
console.error("Failed to fetch bookmarks", e)
}
}, [user])
useEffect(() => {
fetchBookmarks()
}, [fetchBookmarks])
const toggleBookmark = useCallback(async (novelId: string) => {
if (!user) return
// Optimistic update
setBookmarks((prev) => {
const exists = prev.find((b) => b.novelId === novelId)
if (exists) {
return prev.filter((b) => b.novelId !== novelId)
}
return [...prev, { novelId, addedAt: new Date().toISOString() } as any]
})
try {
const res = await fetch("/api/user/bookmarks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "toggle", novelId })
})
if (!res.ok) {
throw new Error("Không thể cập nhật đánh dấu")
}
await fetchBookmarks()
} catch (e) {
console.error(e)
await fetchBookmarks()
}
}, [fetchBookmarks, user])
const updateProgress = useCallback(async (novelId: string, chapterId: string, chapterNumber: number) => {
if (!user) return
// Optimistic update
setBookmarks((prev) => {
const exists = prev.find((b) => b.novelId === novelId)
if (exists) {
return prev.map(b => b.novelId === novelId ? { ...b, lastChapterId: chapterId, lastChapterNumber: chapterNumber } : b)
}
return [...prev, { novelId, lastChapterId: chapterId, lastChapterNumber: chapterNumber, addedAt: new Date().toISOString() } as any]
})
try {
const res = await fetch("/api/user/bookmarks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "updateProgress", novelId, lastChapterId: chapterId, lastChapterNumber: chapterNumber })
})
if (!res.ok) {
throw new Error("Không thể cập nhật tiến độ")
}
await fetchBookmarks()
} catch (e) {
console.error(e)
await fetchBookmarks()
}
}, [fetchBookmarks, user])
const getProgress = useCallback((novelId: string) => {
return bookmarks.find((b) => b.novelId === novelId)
}, [bookmarks])
const isBookmarked = useCallback((novelId: string) => {
return bookmarks.some((b) => b.novelId === novelId)
}, [bookmarks])
return (
<BookmarkContext.Provider value={{ bookmarks, isBookmarked, toggleBookmark, updateProgress, getProgress }}>
{children}
</BookmarkContext.Provider>
)
}
export function useBookmarks() {
const context = useContext(BookmarkContext)
if (!context) throw new Error("useBookmarks must be used within BookmarkProvider")
return context
}
+25
View File
@@ -0,0 +1,25 @@
export const MOD_AI_PREFILL_STORAGE_KEY = "mod:ai-tool:novel-prefill"
export const MOD_AI_MODEL_STORAGE_KEY = "mod:ai-tool:model"
export const MOD_AI_WEB_DEFAULT_MODEL = "gpt-4o-mini-search-preview"
export const MOD_AI_WEB_MODEL_OPTIONS = [
{
value: "gpt-4o-mini-search-preview",
label: "gpt-4o-mini-search-preview (nhanh)",
},
{
value: "gpt-4o-search-preview",
label: "gpt-4o-search-preview (chat luong cao)",
},
] as const
export type AINovelPrefillPayload = {
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[]
}
+30
View File
@@ -0,0 +1,30 @@
import mongoose, { Schema, Document } from "mongoose"
export interface IChapter extends Document {
novelId: string // Trỏ tới ID trong PostgreSQL
number: number
volumeNumber?: number
volumeTitle?: string
volumeChapterNumber?: number
title: string
content: string
views: number
createdAt: Date
}
const ChapterSchema: Schema = new Schema({
novelId: { type: String, required: true, index: true },
number: { type: Number, required: true },
volumeNumber: { type: Number, default: null },
volumeTitle: { type: String, default: null },
volumeChapterNumber: { type: Number, default: null },
title: { type: String, required: true },
content: { type: String, required: true },
views: { type: Number, default: 0 },
createdAt: { type: Date, default: Date.now },
})
ChapterSchema.index({ novelId: 1, number: 1 }, { unique: true })
ChapterSchema.index({ createdAt: -1, novelId: 1 })
export const Chapter = mongoose.models.Chapter || mongoose.model<IChapter>("Chapter", ChapterSchema)
+25
View File
@@ -0,0 +1,25 @@
import mongoose, { Schema, Document } from "mongoose"
export interface IEditorRecommendation extends Document {
novelId: string
editorId: string
createdAt: Date
updatedAt: Date
}
const EditorRecommendationSchema: Schema = new Schema(
{
novelId: { type: String, required: true, index: true },
editorId: { type: String, required: true, index: true },
},
{
timestamps: true,
}
)
EditorRecommendationSchema.index({ novelId: 1, editorId: 1 }, { unique: true })
EditorRecommendationSchema.index({ createdAt: -1 })
export const EditorRecommendation =
mongoose.models.EditorRecommendation ||
mongoose.model<IEditorRecommendation>("EditorRecommendation", EditorRecommendationSchema)
+25
View File
@@ -0,0 +1,25 @@
import mongoose, { Document, Schema } from "mongoose"
export interface IUserRecommendation extends Document {
userId: string
novelId: string
createdAt: Date
updatedAt: Date
}
const UserRecommendationSchema: Schema = new Schema(
{
userId: { type: String, required: true, index: true },
novelId: { type: String, required: true, index: true },
},
{
timestamps: true,
}
)
UserRecommendationSchema.index({ userId: 1, novelId: 1 }, { unique: true })
UserRecommendationSchema.index({ createdAt: -1 })
export const UserRecommendation =
mongoose.models.UserRecommendation ||
mongoose.model<IUserRecommendation>("UserRecommendation", UserRecommendationSchema)
+39
View File
@@ -0,0 +1,39 @@
import mongoose from "mongoose"
let cached = (global as any).mongoose
if (!cached) {
cached = (global as any).mongoose = { conn: null, promise: null }
}
async function connectToMongoDB() {
const mongodbUri = process.env.MONGODB_URI
if (!mongodbUri) {
throw new Error("Please define the MONGODB_URI environment variable")
}
if (cached.conn) {
return cached.conn
}
if (!cached.promise) {
const opts = {
bufferCommands: false,
}
cached.promise = mongoose.connect(mongodbUri, opts).then((mongoose) => {
return mongoose
})
}
try {
cached.conn = await cached.promise
} catch (e) {
cached.promise = null
throw e
}
return cached.conn
}
export default connectToMongoDB
+17
View File
@@ -0,0 +1,17 @@
export function getNovelStatusBadgeClass(status: string): string {
const normalized = status.trim().toLowerCase()
if (normalized.includes("hoàn")) {
return "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300"
}
if (normalized.includes("tạm")) {
return "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300"
}
if (normalized.includes("drop") || normalized.includes("hủy") || normalized.includes("cancel")) {
return "bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300"
}
return "bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-300"
}
+11
View File
@@ -0,0 +1,11 @@
import { PrismaClient } from "@prisma/client"
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }
export const prisma =
globalForPrisma.prisma ||
new PrismaClient({
// log: ["query"], // uncomment during debug
})
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma
+140
View File
@@ -0,0 +1,140 @@
import { DeleteObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3"
import path from "path"
type UploadToR2Options = {
buffer: Buffer
contentType?: string | null
keyPrefix?: string
fileNameHint?: string
}
let cachedClient: S3Client | null = null
function requiredEnv(name: string): string {
const value = process.env[name]
if (!value) {
throw new Error(`Missing required environment variable: ${name}`)
}
return value
}
function optionalEnv(name: string): string | null {
const value = process.env[name]
return value ? value : null
}
function getR2Config() {
const accountId = requiredEnv("R2_ACCOUNT_ID")
const accessKeyId = requiredEnv("R2_ACCESS_KEY_ID")
const secretAccessKey = requiredEnv("R2_SECRET_ACCESS_KEY")
const bucket = requiredEnv("R2_BUCKET_NAME")
const publicBaseUrl = requiredEnv("R2_PUBLIC_BASE_URL")
return {
accountId,
accessKeyId,
secretAccessKey,
bucket,
publicBaseUrl: publicBaseUrl.replace(/\/+$/, ""),
}
}
function getR2Client(): S3Client {
if (cachedClient) return cachedClient
const { accountId, accessKeyId, secretAccessKey } = getR2Config()
cachedClient = new S3Client({
region: "auto",
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId,
secretAccessKey,
},
})
return cachedClient
}
function extensionFromMimeType(mimeType: string | null | undefined): string {
if (!mimeType) return ".jpg"
const normalized = mimeType.toLowerCase()
if (normalized.includes("png")) return ".png"
if (normalized.includes("webp")) return ".webp"
if (normalized.includes("gif")) return ".gif"
if (normalized.includes("avif")) return ".avif"
if (normalized.includes("jpeg") || normalized.includes("jpg")) return ".jpg"
return ".jpg"
}
function extensionFromHint(fileNameHint?: string): string {
if (!fileNameHint) return ""
const ext = path.extname(fileNameHint).toLowerCase()
if (!ext) return ""
if (!/^\.[a-z0-9]{1,8}$/.test(ext)) return ""
return ext
}
export async function uploadBufferToR2(options: UploadToR2Options): Promise<string> {
const client = getR2Client()
const { bucket, publicBaseUrl } = getR2Config()
const keyPrefix = (options.keyPrefix || "covers").replace(/^\/+|\/+$/g, "")
const ext = extensionFromHint(options.fileNameHint) || extensionFromMimeType(options.contentType)
const key = `${keyPrefix}/${Date.now()}-${Math.round(Math.random() * 1e9)}${ext}`
await client.send(
new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: options.buffer,
ContentType: options.contentType || "application/octet-stream",
})
)
return `${publicBaseUrl}/${key}`
}
export function getR2ObjectKeyFromUrl(url: string | null | undefined): string | null {
if (!url) return null
const publicBaseUrl = optionalEnv("R2_PUBLIC_BASE_URL")?.replace(/\/+$/, "")
if (!publicBaseUrl) return null
let baseUrl: URL
let fileUrl: URL
try {
baseUrl = new URL(publicBaseUrl)
fileUrl = new URL(url)
} catch {
return null
}
if (baseUrl.origin !== fileUrl.origin) return null
const basePath = baseUrl.pathname.replace(/\/+$/, "")
if (!fileUrl.pathname.startsWith(basePath)) return null
const relativePath = fileUrl.pathname.slice(basePath.length).replace(/^\/+/, "")
if (!relativePath) return null
return decodeURIComponent(relativePath)
}
export async function deleteR2ObjectByUrl(url: string | null | undefined): Promise<boolean> {
const key = getR2ObjectKeyFromUrl(url)
if (!key) return false
const client = getR2Client()
const { bucket } = getR2Config()
await client.send(
new DeleteObjectCommand({
Bucket: bucket,
Key: key,
})
)
return true
}
+125
View File
@@ -0,0 +1,125 @@
"use client"
import { createContext, ReactNode, useCallback, useContext, useEffect, useState } from "react"
import { useAuth } from "@/lib/auth-context"
type UserRecommendedNovel = {
id: string
title: string
slug: string
authorName: string
coverUrl: string | null
status: string
totalChapters: number
}
type UserRecommendationItem = {
id: string
novelId: string
createdAt: string | null
novel: UserRecommendedNovel
}
type RecommendationContextType = {
recommendations: UserRecommendationItem[]
isRecommended: (novelId: string) => boolean
toggleRecommendation: (novelId: string) => Promise<{ status: "added" | "removed" | "exists" }>
}
const RecommendationContext = createContext<RecommendationContextType | undefined>(undefined)
export function RecommendationProvider({ children }: { children: ReactNode }) {
const { user } = useAuth()
const [recommendations, setRecommendations] = useState<UserRecommendationItem[]>([])
const fetchRecommendations = useCallback(async () => {
if (!user) {
setRecommendations([])
return
}
try {
const res = await fetch("/api/user/recommendations")
if (!res.ok) {
setRecommendations([])
return
}
const data = await res.json()
setRecommendations(Array.isArray(data) ? data : [])
} catch (error) {
console.error("Failed to fetch recommendations", error)
}
}, [user])
useEffect(() => {
fetchRecommendations()
}, [fetchRecommendations])
const isRecommended = useCallback(
(novelId: string) => recommendations.some((item) => item.novelId === novelId),
[recommendations]
)
const toggleRecommendation = useCallback(
async (novelId: string) => {
if (!user) throw new Error("Unauthorized")
if (!novelId) throw new Error("Missing novel id")
const existed = recommendations.some((item) => item.novelId === novelId)
if (existed) {
const res = await fetch(`/api/user/recommendations?novelId=${encodeURIComponent(novelId)}`, {
method: "DELETE",
})
const data = (await res.json()) as { error?: string }
if (!res.ok) {
throw new Error(data.error || "Không thể bỏ đề cử")
}
await fetchRecommendations()
return { status: "removed" as const }
}
const res = await fetch("/api/user/recommendations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ novelId }),
})
const data = (await res.json()) as { error?: string }
if (!res.ok) {
if (res.status === 409) {
await fetchRecommendations()
return { status: "exists" as const }
}
throw new Error(data.error || "Không thể đề cử truyện")
}
await fetchRecommendations()
return { status: "added" as const }
},
[fetchRecommendations, recommendations, user]
)
return (
<RecommendationContext.Provider
value={{
recommendations,
isRecommended,
toggleRecommendation,
}}
>
{children}
</RecommendationContext.Provider>
)
}
export function useRecommendations() {
const context = useContext(RecommendationContext)
if (!context) throw new Error("useRecommendations must be used within RecommendationProvider")
return context
}
+72
View File
@@ -0,0 +1,72 @@
export interface Genre {
id: string
name: string
slug: string
description: string
icon: string
}
export interface Novel {
id: string
title: string
slug: string
authorName: string
series?: {
id: string
name: string
slug: string
} | null
coverColor: string
description: string
genres: string[]
status: "Đang ra" | "Hoàn thành" | "Tạm ngưng"
totalChapters: number
views: number
rating: number
ratingCount: number
bookmarkCount: number
lastUpdated: string
createdAt: string
}
export interface Chapter {
id: string
novelId: string
number: number
volumeNumber?: number
volumeTitle?: string
volumeChapterNumber?: number
title: string
content: string
views: number
createdAt: string
}
export interface User {
id: string
username: string
email: string
avatarColor: string
avatarUrl?: string
role?: "USER" | "MOD" | "ADMIN"
createdAt: string
}
export interface Comment {
id: string
userId: string
username: string
avatarColor: string
novelId: string
chapterId?: string
content: string
createdAt: string
}
export interface Bookmark {
novelId: string
lastChapterId?: string
lastChapterNumber?: number
addedAt: string
novel?: any
}
+27
View File
@@ -0,0 +1,27 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function slugify(str: string) {
if (!str) return ""
return str
.toLowerCase()
.replace(/a|á|à|ả|ã|ạ|ă|ắ|ằ|ẳ|ẵ|ặ|â|ấ|ầ|ẩ|ẫ|ậ/gi, 'a')
.replace(/e|é|è|ẻ|ẽ|ẹ|ê|ế|ề|ể|ễ|ệ/gi, 'e')
.replace(/i|í|ì|ỉ|ĩ|ị/gi, 'i')
.replace(/o|ó|ò|ỏ|õ|ọ|ô|ố|ồ|ổ|ỗ|ộ|ơ|ớ|ờ|ở|ỡ|ợ/gi, 'o')
.replace(/u|ú|ù|ủ|ũ|ụ|ư|ứ|ừ|ử|ữ|ự/gi, 'u')
.replace(/y|ý|ỳ|ỷ|ỹ|ỵ/gi, 'y')
.replace(/đ/gi, 'd')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
export function formatViews(views: number): string {
if (views >= 1000000) return (views / 1000000).toFixed(1) + "M"
if (views >= 1000) return (views / 1000).toFixed(1) + "K"
return views.toString()
}
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+12
View File
@@ -0,0 +1,12 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
typescript: {
ignoreBuildErrors: true,
},
images: {
unoptimized: true,
},
}
export default nextConfig
+84
View File
@@ -0,0 +1,84 @@
{
"name": "reader-api",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 3001",
"build": "next build",
"start": "next start -p 3001",
"lint": "eslint ."
},
"dependencies": {
"@auth/prisma-adapter": "^2.11.1",
"@aws-sdk/client-s3": "^3.1006.0",
"@hookform/resolvers": "^3.9.1",
"@prisma/client": "^5.22.0",
"@radix-ui/react-accordion": "1.2.12",
"@radix-ui/react-alert-dialog": "1.1.15",
"@radix-ui/react-aspect-ratio": "1.1.8",
"@radix-ui/react-avatar": "1.1.11",
"@radix-ui/react-checkbox": "1.3.3",
"@radix-ui/react-collapsible": "1.1.12",
"@radix-ui/react-context-menu": "2.2.16",
"@radix-ui/react-dialog": "1.1.15",
"@radix-ui/react-dropdown-menu": "2.1.16",
"@radix-ui/react-hover-card": "1.1.15",
"@radix-ui/react-label": "2.1.8",
"@radix-ui/react-menubar": "1.1.16",
"@radix-ui/react-navigation-menu": "1.2.14",
"@radix-ui/react-popover": "1.1.15",
"@radix-ui/react-progress": "1.1.8",
"@radix-ui/react-radio-group": "1.3.8",
"@radix-ui/react-scroll-area": "1.2.10",
"@radix-ui/react-select": "2.2.6",
"@radix-ui/react-separator": "1.1.8",
"@radix-ui/react-slider": "1.3.6",
"@radix-ui/react-slot": "1.2.4",
"@radix-ui/react-switch": "1.2.6",
"@radix-ui/react-tabs": "1.1.13",
"@radix-ui/react-toast": "1.2.15",
"@radix-ui/react-toggle": "1.1.10",
"@radix-ui/react-toggle-group": "1.1.11",
"@radix-ui/react-tooltip": "1.2.8",
"@vercel/analytics": "1.6.1",
"autoprefixer": "^10.4.20",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "1.1.1",
"date-fns": "4.1.0",
"embla-carousel-react": "8.6.0",
"epub2": "^3.0.2",
"html-to-text": "^9.0.5",
"input-otp": "1.4.2",
"lucide-react": "^0.564.0",
"mammoth": "^1.11.0",
"mongoose": "^9.2.4",
"next": "16.1.6",
"next-auth": "^4.24.13",
"next-themes": "^0.4.6",
"prisma": "^5.22.0",
"react": "19.2.4",
"react-day-picker": "9.13.2",
"react-dom": "19.2.4",
"react-hook-form": "^7.54.1",
"react-resizable-panels": "^2.1.7",
"recharts": "2.15.0",
"sonner": "^1.7.1",
"tailwind-merge": "^3.3.1",
"vaul": "^1.1.2",
"zod": "^3.24.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.0",
"@types/html-to-text": "^9.0.4",
"@types/node": "^22",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"dotenv": "^17.3.1",
"pg": "^8.20.0",
"postcss": "^8.5",
"tailwindcss": "^4.2.0",
"tw-animate-css": "1.3.3",
"typescript": "5.7.3"
}
}
+5314
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
allowBuilds:
'@prisma/client': false
'@prisma/engines': false
prisma: false
sharp: false
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
'@tailwindcss/postcss': {},
},
}
export default config
+188
View File
@@ -0,0 +1,188 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// NextAuth schema
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
image String?
role Role @default(USER) // Bổ sung phân quyền, mặc định là USER
accounts Account[]
sessions Session[]
comments Comment[]
bookmarks Bookmark[]
novels Novel[] @relation("AuthorNovels") // Truyện do người này (MOD/ADMIN) đăng
settings UserSetting?
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
model UserSetting {
id String @id @default(cuid())
userId String @unique
fontSize Float @default(18)
lineHeight Float @default(1.8)
letterSpacing Float @default(0)
fontFamily String @default("font-serif")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
// Custom App Schema
enum Role {
USER
MOD
ADMIN
}
model Novel {
id String @id @default(cuid())
title String
originalTitle String?
slug String @unique
seriesId String?
series Series? @relation(fields: [seriesId], references: [id], onDelete: SetNull)
authorName String // Tên tác giả nguyên bản của truyện
originalAuthorName String?
uploaderId String? // Tham chiếu đến User (Mod/Admin) đã upload
uploader User? @relation("AuthorNovels", fields: [uploaderId], references: [id], onDelete: SetNull)
description String @db.Text
coverColor String?
coverUrl String?
status String @default("Đang ra") // "Đang ra", "Hoàn thành", "Tạm ngưng"
totalChapters Int @default(0)
views Int @default(0)
rating Float @default(0.0)
ratingCount Int @default(0)
bookmarkCount Int @default(0)
trashWords String[] @default([])
genres NovelGenre[]
comments Comment[]
bookmarks Bookmark[]
dailyViews NovelViewDaily[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model NovelViewDaily {
id String @id @default(cuid())
novelId String
day DateTime @db.Date
views Int @default(0)
novel Novel @relation(fields: [novelId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([novelId, day])
@@index([day])
}
model Series {
id String @id @default(cuid())
name String
slug String @unique
description String? @db.Text
novels Novel[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Genre {
id String @id @default(cuid())
name String @unique
slug String @unique
description String? @db.Text
icon String?
novels NovelGenre[]
}
// Cầu nối nhiều-nhiều giữa Novel và Genre
model NovelGenre {
novelId String
genreId String
novel Novel @relation(fields: [novelId], references: [id], onDelete: Cascade)
genre Genre @relation(fields: [genreId], references: [id], onDelete: Cascade)
@@id([novelId, genreId])
}
model Comment {
id String @id @default(cuid())
content String @db.Text
userId String
novelId String
chapterId String? // Có thể bình luận riêng tư cho từng chương (Lưu chapterId từ MongoDB)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
novel Novel @relation(fields: [novelId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Bookmark {
id String @id @default(cuid())
userId String
novelId String
lastChapterId String?
lastChapterNumber Int?
readChapters Int[] @default([])
hasCountedView Boolean @default(false)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
novel Novel @relation(fields: [novelId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique([userId, novelId])
}
+49
View File
@@ -0,0 +1,49 @@
const { Client } = require('pg');
require('dotenv').config({ path: '.env.local' });
async function checkAndCreateDatabase() {
const dbUrl = process.env.DATABASE_URL;
if (!dbUrl) {
console.error('DATABASE_URL is not set in .env.local');
process.exit(1);
}
// Phân tích DATABASE_URL
const match = dbUrl.match(/postgresql:\/\/([^:]+):([^@]+)@([^:]+):(\d+)\/([^?]+)/);
if (!match) {
console.error('Invalid DATABASE_URL format');
process.exit(1);
}
const [_, user, password, host, port, database] = match;
// Kết nối đến database mặc định 'postgres'
const client = new Client({
user,
password: decodeURIComponent(password),
host,
port: parseInt(port),
database: 'postgres',
});
try {
await client.connect();
const res = await client.query(`SELECT 1 FROM pg_database WHERE datname = $1`, [database]);
if (res.rowCount === 0) {
console.log(`Bắt đầu tạo database "${database}"...`);
await client.query(`CREATE DATABASE "${database}"`);
console.log(`Đã tạo thành công database "${database}".`);
} else {
console.log(`Database "${database}" đã tồn tại, bỏ qua tạo mới.`);
}
} catch (error) {
console.error('Lỗi khi kiểm tra/tạo database:', error);
process.exit(1);
} finally {
await client.end();
}
}
checkAndCreateDatabase();
+60
View File
@@ -0,0 +1,60 @@
const { PrismaClient } = require('@prisma/client')
const mongoose = require('mongoose')
require('dotenv').config({ path: '.env.local' })
require('dotenv').config()
const prisma = new PrismaClient()
async function main() {
console.log('Connecting to MongoDB...')
// Connect to MongoDB using MONGODB_URI
const mongoUri = process.env.MONGODB_URI
if (!mongoUri) {
throw new Error('MONGODB_URI is not defined in env')
}
await mongoose.connect(mongoUri)
// Wipe MongoDB Chapters
console.log('Wiping chapters from MongoDB...')
try {
const chapterSchema = new mongoose.Schema({}, { strict: false })
const Chapter = mongoose.models.Chapter || mongoose.model('Chapter', chapterSchema, 'chapters')
const res = await Chapter.deleteMany({})
console.log(`Deleted ${res.deletedCount} chapters.`)
} catch (e) {
console.error('Error wiping mongo chapters', e)
}
// Wipe PostgreSQL Content
console.log('Wiping Novels, Genres, Comments, Bookmarks from PostgreSQL...')
try {
// Delete in order to respect foreign keys if Cascade isn't perfect, but Cascade is set on most.
await prisma.comment.deleteMany({})
console.log('Deleted all comments.')
await prisma.bookmark.deleteMany({})
console.log('Deleted all bookmarks.')
await prisma.novelGenre.deleteMany({})
console.log('Deleted all novel_genres.')
await prisma.genre.deleteMany({})
console.log('Deleted all genres.')
await prisma.novel.deleteMany({})
console.log('Deleted all novels.')
} catch (error) {
console.error('Error wiping postgres', error)
}
console.log('Cleanup complete.')
}
main()
.catch(console.error)
.finally(async () => {
await prisma.$disconnect()
await mongoose.disconnect()
process.exit(0)
})
+68
View File
@@ -0,0 +1,68 @@
require('dotenv').config({ path: '.env' });
async function fetchJsonWithTimeout(url, init, timeoutMs = 25000) {
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, 500)}`);
}
return await res.json();
} finally {
clearTimeout(timeout);
}
}
const prompt = "Please reply with { \"ready\": true } in JSON";
async function tryGoogle() {
const apiKey = process.env.GOOGLE_AI_KEY;
if (!apiKey) return console.log("Google: No key");
try {
const data = await fetchJsonWithTimeout(
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${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 },
}),
}
);
console.log("Google Success:", JSON.stringify(data).slice(0, 100));
} catch(e) { console.log("Google Error:", e.stack || e.message); }
}
async function tryDeepSeek() {
const apiKey = process.env.DEEKSEEK_KEY || process.env.DEEPSEEK_KEY;
if (!apiKey) return console.log("DeepSeek: No key");
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: "deepseek-chat",
temperature: 0.2,
max_tokens: 500,
messages: [{ role: "user", content: prompt }],
}),
}
);
console.log("DeepSeek Success:", JSON.stringify(data).slice(0,100));
} catch(e) { console.log("DeepSeek Error:", e.stack || e.message); }
}
async function run() {
await tryGoogle();
await tryDeepSeek();
}
run();
+17
View File
@@ -0,0 +1,17 @@
const { MongoClient } = require('mongodb');
async function check() {
const uri = process.env.MONGODB_URI || "mongodb://localhost:27017/reader";
const client = new MongoClient(uri);
try {
await client.connect();
const db = client.db();
// Just find the latest 5 chapters inserted
const docs = await db.collection('Chapter').find({}).sort({ _id: -1 }).limit(10).toArray();
console.log("Recent chapters:");
docs.forEach(d => console.log(d.novelId, d.number, d.title));
} finally {
await client.close();
}
}
check();
+30
View File
@@ -0,0 +1,30 @@
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
async function main() {
const isMissingFilter = true
const baseWhereOptions: any[] = [{}]
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 },
orderBy: [{ updatedAt: "desc" }],
take: 25,
skip: 0,
})
console.log("Returned rows length:", rows.length)
}
main().finally(() => prisma.$disconnect())
+41
View File
@@ -0,0 +1,41 @@
{
"compilerOptions": {
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"target": "ES6",
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}
File diff suppressed because one or more lines are too long
+17
View File
@@ -0,0 +1,17 @@
import NextAuth from "next-auth"
declare module "next-auth" {
interface Session {
user: {
id: string
name?: string | null
email?: string | null
image?: string | null
role: string
}
}
interface User {
role: string
}
}