generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } enum Role { READER AUTHOR ADMIN } enum TransactionType { DEPOSIT CHAPTER_UNLOCK DONATION DAILY_REWARD STREAK_REWARD VIP_PURCHASE } model User { id String @id @default(uuid()) email String @unique passwordHash String? fullName String avatar String? banner String? bio String? role Role @default(READER) // Economy & VIP coins Int @default(0) vipUntil DateTime? // Gamification level Int @default(1) exp Int @default(0) currentStreak Int @default(0) longestStreak Int @default(0) lastReadAt DateTime? lastCheckInAt DateTime? // Relations novels Novel[] @relation("AuthorNovels") bookmarks Bookmark[] unlockedChapters UnlockedChapter[] transactions Transaction[] checkIns CheckInLog[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Novel { id String @id @default(uuid()) title String slug String @unique summary String @db.Text coverImage String authorId String author User @relation("AuthorNovels", fields: [authorId], references: [id]) status String @default("ONGOING") // ONGOING, COMPLETED, PAUSED views Int @default(0) likes Int @default(0) chapters Chapter[] categories CategoryOnNovel[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Chapter { id String @id @default(uuid()) novelId String novel Novel @relation(fields: [novelId], references: [id], onDelete: Cascade) chapterNum Float title String content String @db.Text isVip Boolean @default(false) price Int @default(0) unlockedBy UnlockedChapter[] createdAt DateTime @default(now()) } model UnlockedChapter { id String @id @default(uuid()) userId String user User @relation(fields: [userId], references: [id]) chapterId String chapter Chapter @relation(fields: [chapterId], references: [id]) createdAt DateTime @default(now()) @@unique([userId, chapterId]) } model Category { id String @id @default(uuid()) name String @unique slug String @unique novels CategoryOnNovel[] } model CategoryOnNovel { novelId String novel Novel @relation(fields: [novelId], references: [id]) categoryId String category Category @relation(fields: [categoryId], references: [id]) @@id([novelId, categoryId]) } model Transaction { id String @id @default(uuid()) userId String user User @relation(fields: [userId], references: [id]) amount Int // Positive for credit, negative for debit type TransactionType description String createdAt DateTime @default(now()) } model CheckInLog { id String @id @default(uuid()) userId String user User @relation(fields: [userId], references: [id]) dayCount Int // Số ngày điểm danh liên tiếp trong chu kỳ coinsEarned Int createdAt DateTime @default(now()) } model Bookmark { userId String user User @relation(fields: [userId], references: [id]) novelId String novel Novel @relation(fields: [novelId], references: [id]) createdAt DateTime @default(now()) @@id([userId, novelId]) } // src/chapter/chapter.service.ts import { Injectable, BadRequestException, ForbiddenException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; @Injectable() export class ChapterService { constructor(private prisma: PrismaService) {} async unlockChapter(userId: string, chapterId: string) { const chapter = await this.prisma.chapter.findUnique({ where: { id: chapterId } }); if (!chapter) throw new BadRequestException('Chương không tồn tại'); if (!chapter.isVip) return { message: 'Chương này miễn phí' }; const user = await this.prisma.user.findUnique({ where: { id: userId } }); // Kiểm tra VIP còn hạn -> Giảm giá 20% const isVipActive = user?.vipUntil && new Date(user.vipUntil) > new Date(); const finalPrice = isVipActive ? Math.floor(chapter.price * 0.8) : chapter.price; if ((user?.coins ?? 0) < finalPrice) { throw new ForbiddenException('Bạn không đủ xu để mở khóa chương này'); } // Kiểm tra xem đã mở khóa chưa const existing = await this.prisma.unlockedChapter.findUnique({ where: { userId_chapterId: { userId, chapterId } }, }); if (existing) return { message: 'Chương đã được mở khóa từ trước' }; return await this.prisma.$transaction(async (tx) => { // Trừ xu user await tx.user.update({ where: { id: userId }, data: { coins: { decrement: finalPrice } }, }); // Tạo record đã unlock await tx.unlockedChapter.create({ data: { userId, chapterId }, }); // Tạo nhật ký giao dịch await tx.transaction.create({ data: { userId, amount: -finalPrice, type: 'CHAPTER_UNLOCK', description: `Mở khóa chương ${chapter.chapterNum}: ${chapter.title}`, }, }); return { success: true, pricePaid: finalPrice }; }); } } // src/gamification/gamification.service.ts import { Injectable, BadRequestException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; @Injectable() export class GamificationService { constructor(private prisma: PrismaService) {} async dailyCheckIn(userId: string) { const user = await this.prisma.user.findUnique({ where: { id: userId } }); if (!user) throw new BadRequestException('User không tồn tại'); const today = new Date(); today.setHours(0, 0, 0, 0); if (user.lastCheckInAt) { const lastCheckIn = new Date(user.lastCheckInAt); lastCheckIn.setHours(0, 0, 0, 0); if (lastCheckIn.getTime() === today.getTime()) { throw new BadRequestException('Hôm nay bạn đã điểm danh rồi!'); } } // Tính ngày liên tiếp let streak = 1; if (user.lastCheckInAt) { const yesterday = new Date(today); yesterday.setDate(yesterday.getDate() - 1); const lastCheckIn = new Date(user.lastCheckInAt); lastCheckIn.setHours(0, 0, 0, 0); if (lastCheckIn.getTime() === yesterday.getTime()) { streak = (user.currentStreak % 30) + 1; } } // Công thức tính thưởng Xu const baseCoins = 20; const bonusCoins = Math.min(streak * 5, 100); const totalCoins = baseCoins + bonusCoins; return await this.prisma.$transaction(async (tx) => { // Cập nhật User const updatedUser = await tx.user.update({ where: { id: userId }, data: { coins: { increment: totalCoins }, exp: { increment: 50 }, currentStreak: streak, longestStreak: Math.max(streak, user.longestStreak), lastCheckInAt: new Date(), }, }); // Ghi nhật ký giao dịch await tx.transaction.create({ data: { userId, amount: totalCoins, type: 'DAILY_REWARD', description: `Thưởng điểm danh ngày ${streak}`, }, }); // Ghi log điểm danh await tx.checkInLog.create({ data: { userId, dayCount: streak, coinsEarned: totalCoins, }, }); return { coinsEarned: totalCoins, currentStreak: streak, totalCoins: updatedUser.coins, }; }); } } // components/Reader.tsx 'use client'; import { useState } from 'react'; import { motion } from 'framer-motion'; import { Sun, Moon, BookOpen, Settings } from 'lucide-react'; interface Props { title: string; chapterNum: number; content: string; } export default function Reader({ title, chapterNum, content }: Props) { const [theme, setTheme] = useState<'dark' | 'light' | 'sepia'>('dark'); const [fontSize, setFontSize] = useState(18); const [lineHeight, setLineHeight] = useState(1.8); const themeClasses = { dark: 'bg-[#0f172a] text-[#e2e8f0]', light: 'bg-[#ffffff] text-[#1e293b]', sepia: 'bg-[#fbf0d9] text-[#5f4b32]', }; return (
{/* Dynamic Header */}

Chương {chapterNum}: {title}

{/* Toolbar */}
{fontSize}px
{/* Novel Content */}
{content}
); } // components/CheckInModal.tsx 'use client'; import { useState } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { Gift, Zap, CheckCircle2 } from 'lucide-react'; export default function CheckInModal({ currentStreak = 3 }: { currentStreak?: number }) { const [isOpen, setIsOpen] = useState(false); const [claimed, setClaimed] = useState(false); const handleCheckIn = async () => { // Gọi API `/api/gamification/check-in` setClaimed(true); }; return ( <> {isOpen && (

Điểm Danh Hằng Ngày

Chuỗi hiện tại: {currentStreak} ngày 🔥

{/* Grid 7 ngày mẫu */}
{[1, 2, 3, 4, 5, 6, 7].map((day) => (
Ngày {day} +{20 + day * 5}
))}
)}
); }