Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -0,0 +1,10 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.venv/
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
.DS_Store
|
||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# PostgreSQL + MongoDB
|
||||||
|
DATABASE_URL=postgresql://reader:reader@localhost:5432/reader
|
||||||
|
MONGODB_URI=mongodb://localhost:27017/reader
|
||||||
|
|
||||||
|
# Auth / OAuth
|
||||||
|
NEXTAUTH_SECRET=replace-with-strong-secret
|
||||||
|
MOBILE_JWT_SECRET=replace-with-strong-secret
|
||||||
|
# Comma-separated allowed Google OAuth client IDs (web + android if needed)
|
||||||
|
GOOGLE_CLIENT_ID=web-client-id.apps.googleusercontent.com,android-client-id.apps.googleusercontent.com
|
||||||
|
|
||||||
|
# CORS (comma-separated), * for all in local dev
|
||||||
|
CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
|
||||||
|
|
||||||
|
# Environment label
|
||||||
|
APP_ENV=development
|
||||||
@@ -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-api:latest
|
||||||
|
ghcr.io/fevirtus/reader-api:latest
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
+11
-9
@@ -1,12 +1,14 @@
|
|||||||
# v0 runtime files
|
__pycache__/
|
||||||
__v0_runtime_loader.js
|
*.pyc
|
||||||
__v0_devtools.tsx
|
*.pyo
|
||||||
__v0_jsx-dev-runtime.ts
|
*.pyd
|
||||||
|
.Python
|
||||||
|
.venv/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
# Common ignores
|
|
||||||
node_modules/
|
|
||||||
.next/
|
|
||||||
.env*.local
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.env
|
.env
|
||||||
test-ebook/
|
.env.local
|
||||||
|
# Local debug/test scripts
|
||||||
|
test_*.py
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
# 🐛 Chapter Save Debugging Guide
|
||||||
|
|
||||||
|
## Vấn Đề Đã Fix
|
||||||
|
|
||||||
|
### ✅ Fix 1: Ownership Check (Line 932)
|
||||||
|
- **Vấn đề:** MOD không thể tạo chapter cho truyện mặc định (uploaderId = NULL)
|
||||||
|
- **Fix:** Thêm `OR uploaderId IS NULL` vào WHERE clause
|
||||||
|
- **Dòng:** 932, 1002
|
||||||
|
|
||||||
|
### ✅ Fix 2: Input Validation
|
||||||
|
- **Vấn đề:** Content có thể trống, number có thể âm
|
||||||
|
- **Fix:** Thêm validation trước insert
|
||||||
|
- **Dòng:** 920-927, 997-1004
|
||||||
|
|
||||||
|
### ✅ Fix 3: Data Consistency Logging
|
||||||
|
- **Vấn đề:** Nếu MongoDB insert succeed nhưng PostgreSQL fail → dữ liệu inconsistent
|
||||||
|
- **Fix:** Thêm separate error handling và logging [CRITICAL]
|
||||||
|
- **Dòng:** 956-974
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Testing Checklist
|
||||||
|
|
||||||
|
### A. Network Debugging (Browser DevTools)
|
||||||
|
|
||||||
|
1. **Mở DevTools:** F12 → Network tab
|
||||||
|
2. **Ấn "Lưu chương"**
|
||||||
|
3. **Kiểm tra request `POST /api/mod/chuong`:**
|
||||||
|
- ✅ Status code: `201` = success, `4xx`/`5xx` = error
|
||||||
|
- ✅ Response body: Phần lấy `id`, `number`, `title`
|
||||||
|
- ❌ Status 403: Ownership issue
|
||||||
|
- ❌ Status 400: Duplicate or validation error
|
||||||
|
- ❌ Status 500: Server error (check [CRITICAL] logs)
|
||||||
|
|
||||||
|
### B. MongoDB Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Access MongoDB
|
||||||
|
mongosh # hoặc mongo
|
||||||
|
|
||||||
|
# Switch to database
|
||||||
|
use reader_db # (thay bằng tên DB thực tế)
|
||||||
|
|
||||||
|
# List recent chapters
|
||||||
|
db.chapters.find({}, {novelId: 1, number: 1, title: 1, createdAt: 1})
|
||||||
|
.sort({_id: -1})
|
||||||
|
.limit(5)
|
||||||
|
|
||||||
|
# Check specific novel
|
||||||
|
db.chapters.countDocuments({novelId: "YOUR_NOVEL_ID"})
|
||||||
|
|
||||||
|
# Check for duplicates (race condition)
|
||||||
|
db.chapters.find({novelId: "YOUR_NOVEL_ID", number: 1})
|
||||||
|
```
|
||||||
|
|
||||||
|
### C. PostgreSQL Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Access PostgreSQL
|
||||||
|
psql # hoặc your database client
|
||||||
|
|
||||||
|
# Check novel total chapters count
|
||||||
|
SELECT id, title, "totalChapters" FROM "Novel" WHERE id = 'YOUR_NOVEL_ID';
|
||||||
|
|
||||||
|
# Verify it matches MongoDB count
|
||||||
|
-- MongoDB should have same count as "totalChapters"
|
||||||
|
```
|
||||||
|
|
||||||
|
### D. Server Log Analysis
|
||||||
|
|
||||||
|
Look for these patterns in backend logs:
|
||||||
|
|
||||||
|
```
|
||||||
|
✅ Success:
|
||||||
|
[timestamp] POST /mod/chuong - Status 201
|
||||||
|
[timestamp] Inserted chapter id: xxx
|
||||||
|
|
||||||
|
❌ Issues:
|
||||||
|
[CRITICAL] ⚠️ INCONSISTENT STATE: Chapter inserted in MongoDB...
|
||||||
|
[timestamp] Lỗi MongoDB: [error message]
|
||||||
|
[timestamp] Ownership check failed: 403
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Common Scenarios & Solutions
|
||||||
|
|
||||||
|
### Scenario 1: Network Shows 201 But Chapter Not Visible
|
||||||
|
|
||||||
|
**Cause:** Chapter saved but not refreshed in UI
|
||||||
|
**Solution:**
|
||||||
|
- Press F5 to refresh page
|
||||||
|
- Check MongoDB to confirm chapter exists
|
||||||
|
- Check if `fetchChapters()` was called after save
|
||||||
|
|
||||||
|
### Scenario 2: Network Shows 403 Forbidden
|
||||||
|
|
||||||
|
**Cause:** Novel ownership check failed
|
||||||
|
**Solution:**
|
||||||
|
- Verify you are MOD or ADMIN user
|
||||||
|
- Verify novel exists in PostgreSQL:
|
||||||
|
```sql
|
||||||
|
SELECT id, title, "uploaderId" FROM "Novel" WHERE id = 'YOUR_ID';
|
||||||
|
```
|
||||||
|
- If uploaderId is NULL (default), ensure you're MOD user
|
||||||
|
|
||||||
|
### Scenario 3: Network Shows 400 Bad Request
|
||||||
|
|
||||||
|
**Causes:**
|
||||||
|
- Chapter number already exists
|
||||||
|
- Title or content empty
|
||||||
|
- Chapter number ≤ 0
|
||||||
|
|
||||||
|
**Solution:** Check response detail message and fix input
|
||||||
|
|
||||||
|
### Scenario 4: Network Shows 500 Server Error
|
||||||
|
|
||||||
|
**Cause:** MongoDB or PostgreSQL failure
|
||||||
|
**Solution:**
|
||||||
|
- Check server logs for [CRITICAL] message
|
||||||
|
- If MongoDB failed: Check MongoDB connection
|
||||||
|
- If PostgreSQL failed: Check PostgreSQL connection
|
||||||
|
- Contact admin with error message
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Advanced Debug Commands
|
||||||
|
|
||||||
|
### Check MongoDB Connection Status
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From backend terminal
|
||||||
|
python3 -c "
|
||||||
|
import asyncio
|
||||||
|
from app.database import mongo_db
|
||||||
|
|
||||||
|
async def check():
|
||||||
|
await mongo_db.command('ping')
|
||||||
|
print('✓ MongoDB Connected!')
|
||||||
|
|
||||||
|
asyncio.run(check())
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check PostgreSQL Connection Status
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From backend terminal
|
||||||
|
python3 -c "
|
||||||
|
import asyncio
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
from app.database import SessionLocal
|
||||||
|
|
||||||
|
async def check():
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
result = await session.execute('SELECT 1')
|
||||||
|
print('✓ PostgreSQL Connected!')
|
||||||
|
|
||||||
|
asyncio.run(check())
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manually Sync Total Chapters
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# If totalChapters is out of sync
|
||||||
|
mongosh
|
||||||
|
use reader_db
|
||||||
|
|
||||||
|
# Get count
|
||||||
|
db.chapters.countDocuments({novelId: "YOUR_NOVEL_ID"})
|
||||||
|
|
||||||
|
# Then update PostgreSQL manually:
|
||||||
|
# psql
|
||||||
|
UPDATE "Novel" SET "totalChapters" = 123 WHERE id = 'YOUR_NOVEL_ID';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Test Cases
|
||||||
|
|
||||||
|
### Test 1: Basic Chapter Save
|
||||||
|
```
|
||||||
|
1. Create novel
|
||||||
|
2. Save chapter #1
|
||||||
|
3. ✅ Should appear in chapter list
|
||||||
|
4. ✅ totalChapters should be 1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test 2: Sequential Chapters
|
||||||
|
```
|
||||||
|
1. Save chapters 1, 2, 3
|
||||||
|
2. ✅ All should appear with correct numbers
|
||||||
|
3. ✅ Next chapter field should suggest 4
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test 3: Duplicate Prevention
|
||||||
|
```
|
||||||
|
1. Save chapter #5
|
||||||
|
2. Try to save chapter #5 again
|
||||||
|
3. ✅ Should show "Chương 5 đã tồn tại"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test 4: Default Novel (MOD Permission)
|
||||||
|
```
|
||||||
|
1. Verify a novel with uploaderId = NULL exists
|
||||||
|
2. As MOD user, save chapter to that novel
|
||||||
|
3. ✅ Should succeed (not 403 Forbidden)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test 5: No Empty Content
|
||||||
|
```
|
||||||
|
1. Try to save chapter with empty title
|
||||||
|
2. ✅ Should show "Tiêu đề chương không được trống"
|
||||||
|
3. Try to save chapter with empty content
|
||||||
|
4. ✅ Should show "Nội dung chương không được trống"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🆘 Still Having Issues?
|
||||||
|
|
||||||
|
1. **Run checklist A, B, C above** and collect outputs
|
||||||
|
2. **Screenshot of Network tab response**
|
||||||
|
3. **MongoDB output from `find()`**
|
||||||
|
4. **Server log output (especially [CRITICAL] lines)**
|
||||||
|
5. Share these with: [your-dev-contact]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Monitoring
|
||||||
|
|
||||||
|
### Health Check Script
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# save as monitor-save.sh
|
||||||
|
|
||||||
|
echo "=== Chapter Save System Health Check ==="
|
||||||
|
echo ""
|
||||||
|
echo "1. MongoDB Connection:"
|
||||||
|
# mongosh check here
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "2. PostgreSQL Connection:"
|
||||||
|
# psql check here
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "3. Backend API:"
|
||||||
|
curl -s http://localhost:8000/docs | head -20
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Done ==="
|
||||||
|
```
|
||||||
|
|
||||||
|
Run: `bash monitor-save.sh`
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir uv
|
||||||
|
|
||||||
|
COPY pyproject.toml ./
|
||||||
|
RUN uv sync --no-dev
|
||||||
|
|
||||||
|
COPY app ./app
|
||||||
|
COPY prisma ./prisma
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
# ✅ Chapter Save System - Fixed Issues Summary
|
||||||
|
|
||||||
|
**Date:** 2026-03-24
|
||||||
|
**Status:** All HIGH priority issues fixed ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 Issues Identified & Fixed
|
||||||
|
|
||||||
|
### 1️⃣ **Ownership Check Bypass (HIGH)**
|
||||||
|
- **File:** `app/routers/mod.py`
|
||||||
|
- **Lines:** 932-936 (POST), 1000-1007 (PUT)
|
||||||
|
- **Issue:** MOD couldn't create chapters for default novels (uploaderId = NULL)
|
||||||
|
- **Fix:** Changed query from:
|
||||||
|
```python
|
||||||
|
WHERE id = :nid AND "uploaderId" = :uid
|
||||||
|
```
|
||||||
|
To:
|
||||||
|
```python
|
||||||
|
WHERE id = :nid AND ("uploaderId" = :uid OR "uploaderId" IS NULL)
|
||||||
|
```
|
||||||
|
- **Impact:** ✅ Fixed - MOD can now manage default novels
|
||||||
|
|
||||||
|
### 2️⃣ **Missing Input Validation (HIGH)**
|
||||||
|
- **File:** `app/routers/mod.py`
|
||||||
|
- **Lines:** 920-927 (POST), 997-1004 (PUT)
|
||||||
|
- **Issue:** Could save empty title/content, negative chapter numbers
|
||||||
|
- **Fix:** Added validation:
|
||||||
|
```python
|
||||||
|
if not body.title or not body.title.strip():
|
||||||
|
raise HTTPException(400, "Tiêu đề chương không được trống")
|
||||||
|
if body.number <= 0:
|
||||||
|
raise HTTPException(400, "Số chương phải > 0")
|
||||||
|
```
|
||||||
|
- **Impact:** ✅ Fixed - Invalid data rejected at API level
|
||||||
|
|
||||||
|
### 3️⃣ **Data Inconsistency on PostgreSQL Failure (HIGH)**
|
||||||
|
- **File:** `app/routers/mod.py`
|
||||||
|
- **Lines:** 956-974 (POST)
|
||||||
|
- **Issue:** If MongoDB insert succeeds but PostgreSQL sync fails → inconsistent state
|
||||||
|
- **Fix:** Added separate error handling:
|
||||||
|
```python
|
||||||
|
try:
|
||||||
|
result = await mongo_db.chapters.insert_one(doc)
|
||||||
|
except Exception as mongo_err:
|
||||||
|
raise HTTPException(500, f"Lỗi MongoDB: {str(mongo_err)}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
total = await _sync_total_chapters(db, body.novelId)
|
||||||
|
except Exception as pg_err:
|
||||||
|
# Log [CRITICAL] and alert user
|
||||||
|
raise HTTPException(500, "Dữ liệu has được lưu nhưng...")
|
||||||
|
```
|
||||||
|
- **Impact:** ✅ Fixed - Clear error messages identify MongoDB vs PostgreSQL failures
|
||||||
|
|
||||||
|
### 4️⃣ **Frontend Error Display (MEDIUM)**
|
||||||
|
- **File:** `reader/app/mod/chuong/chapter-client.tsx`
|
||||||
|
- **Line:** 356
|
||||||
|
- **Issue:** Only checked `error` field, not FastAPI's `detail` field
|
||||||
|
- **Fix:** Changed:
|
||||||
|
```javascript
|
||||||
|
if (!res.ok) throw new Error(resData.error || resData.detail || "...")
|
||||||
|
```
|
||||||
|
- **Impact:** ✅ Fixed - Users see actual backend error messages
|
||||||
|
|
||||||
|
### 5️⃣ **Missing Novel Existence Check (MEDIUM)**
|
||||||
|
- **File:** `app/routers/mod.py`
|
||||||
|
- **Lines:** 948-951 (POST)
|
||||||
|
- **Issue:** Could try to save chapter for non-existent novel
|
||||||
|
- **Fix:** Added explicit check:
|
||||||
|
```python
|
||||||
|
novel_check = await db.execute(
|
||||||
|
text('SELECT id FROM "Novel" WHERE id = :nid'),
|
||||||
|
{"nid": body.novelId},
|
||||||
|
)
|
||||||
|
if not novel_check.first():
|
||||||
|
raise HTTPException(404, "Truyện không tồn tại")
|
||||||
|
```
|
||||||
|
- **Impact:** ✅ Fixed - Better error message (404 instead of 500)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Testing Results
|
||||||
|
|
||||||
|
### Build Verification
|
||||||
|
```
|
||||||
|
✅ Python syntax: OK (py_compile passed)
|
||||||
|
✅ Next.js build: OK (all 11 routes successfully compiled)
|
||||||
|
✅ No type errors in modified files
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modified Files
|
||||||
|
| File | Changes | Status |
|
||||||
|
|------|---------|--------|
|
||||||
|
| `app/routers/mod.py` | POST/PUT endpoints refactored with validation | ✅ Fixed |
|
||||||
|
| `reader/app/mod/chuong/chapter-client.tsx` | Error handling improved | ✅ Fixed |
|
||||||
|
| `CHAPTER_SAVE_DEBUG.md` | Debug guide created | ✅ New |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Next Steps for User
|
||||||
|
|
||||||
|
### ⚠️ IMPORTANT: Test the following scenarios
|
||||||
|
|
||||||
|
1. **Basic Save:** Create new novel → save Chapter 1 → verify appears in list
|
||||||
|
2. **Ownership:** Try saving to default novel as MOD user → should succeed now
|
||||||
|
3. **Duplicate:** Try saving same chapter twice → should show "đã tồn tại"
|
||||||
|
4. **Empty Content:** Try saving without title → should show validation error
|
||||||
|
5. **Negative Number:** Try chapter #-1 → should reject
|
||||||
|
|
||||||
|
### If Still Failing:
|
||||||
|
|
||||||
|
1. Open **DevTools Network tab** → F12 → Network
|
||||||
|
2. Try to save chapter
|
||||||
|
3. Look for **POST /api/mod/chuong** request
|
||||||
|
4. Check **Status code** and **Response body**
|
||||||
|
5. Use guide in `CHAPTER_SAVE_DEBUG.md` to troubleshoot
|
||||||
|
|
||||||
|
### Possible Remaining Issues
|
||||||
|
|
||||||
|
⚠️ **Not yet fixed (MEDIUM/LOW priority):**
|
||||||
|
- [ ] Race condition on duplicate chapter check (add MongoDB unique index)
|
||||||
|
- [ ] No MongoDB/PostgreSQL timeout configuration
|
||||||
|
- [ ] Generic exception handler logging (uses traceback.print_exc)
|
||||||
|
- [ ] Missing structured logging system
|
||||||
|
|
||||||
|
These are less critical but could cause issues under high load.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Code Changes Summary
|
||||||
|
|
||||||
|
**Total lines modified:** ~150
|
||||||
|
**Files affected:** 2
|
||||||
|
**Breaking changes:** None (backward compatible)
|
||||||
|
**Rollback difficulty:** Low (simple validation additions)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ What Changed
|
||||||
|
|
||||||
|
```diff
|
||||||
|
# POST /mod/chuong
|
||||||
|
- Missing input validation
|
||||||
|
- Missing novel existence check
|
||||||
|
- Ownership query doesn't allow NULL uploaderId
|
||||||
|
- No separation of MongoDB vs PostgreSQL error handling
|
||||||
|
|
||||||
|
+ Full input validation (title, content, number)
|
||||||
|
+ Novel existence check with clear 404 error
|
||||||
|
+ Ownership check allows both user-owned and default novels
|
||||||
|
+ Separate error handling for MongoDB and PostgreSQL
|
||||||
|
+ Better error messages for debugging
|
||||||
|
+ Data consistency logging ([CRITICAL] alerts)
|
||||||
|
|
||||||
|
# PUT /mod/chuong
|
||||||
|
- Same issues as POST
|
||||||
|
|
||||||
|
+ Same fixes applied
|
||||||
|
|
||||||
|
# Frontend error handling
|
||||||
|
- Ignored FastAPI's 'detail' field
|
||||||
|
|
||||||
|
+ Now checks both 'error' and 'detail' fields
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Monitoring Recommendations
|
||||||
|
|
||||||
|
1. **Set up log monitoring** for `[CRITICAL]` messages
|
||||||
|
2. **Verify MongoDB connection** on startup
|
||||||
|
3. **Verify PostgreSQL connection** on startup
|
||||||
|
4. **Add request logging** to track save operations
|
||||||
|
5. **Monitor totalChapters sync** for discrepancies
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 Support
|
||||||
|
|
||||||
|
If issues persist after testing:
|
||||||
|
1. Follow debugging guide in `CHAPTER_SAVE_DEBUG.md`
|
||||||
|
2. Check Network tab for response codes
|
||||||
|
3. Verify MongoDB and PostgreSQL connectivity
|
||||||
|
4. Look for [CRITICAL] messages in server logs
|
||||||
|
5. Check browser console for JavaScript errors
|
||||||
@@ -1,180 +1,101 @@
|
|||||||
# reader-api
|
# reader-api (FastAPI + UV)
|
||||||
|
|
||||||
Đây là backend API dùng chung cho cả web app `reader` và mobile app `reader-app`.
|
Shared backend API for both:
|
||||||
|
- Web app: reader
|
||||||
|
- Mobile app: reader-app
|
||||||
|
|
||||||
## 🚀 Tính năng nổi bật
|
This project is Python-first (FastAPI), with production-focused Docker setup and healthcheck.
|
||||||
|
|
||||||
- **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).
|
## Stack
|
||||||
- **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
|
- Python 3.11+
|
||||||
|
- FastAPI
|
||||||
|
- UV (package manager / runner)
|
||||||
|
- PostgreSQL (structured data)
|
||||||
|
- MongoDB (chapter content + user recommendations)
|
||||||
|
|
||||||
- **Framework**: [Next.js](https://nextjs.org/) (App Router), React 19
|
## API Base URL
|
||||||
- **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/)
|
|
||||||
|
|
||||||
---
|
- Local dev: http://localhost:8000
|
||||||
|
- Healthcheck: GET /api/health
|
||||||
|
|
||||||
## 💻 Hướng dẫn chạy Local (Phát triển)
|
## Environment
|
||||||
|
|
||||||
### 1. Yêu cầu cài đặt
|
Create `.env` from `.env.example`.
|
||||||
- [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
|
Required keys:
|
||||||
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
|
```env
|
||||||
# URL kết nối PostgreSQL
|
DATABASE_URL=postgresql://reader:reader@localhost:5432/reader
|
||||||
DATABASE_URL="postgresql://user:password@localhost:5432/reader?schema=public"
|
MONGODB_URI=mongodb://localhost:27017/reader
|
||||||
|
NEXTAUTH_SECRET=replace-with-strong-secret
|
||||||
# URL kết nối MongoDB
|
MOBILE_JWT_SECRET=replace-with-strong-secret
|
||||||
MONGODB_URI="mongodb://user:password@localhost:27017/reader?authSource=admin"
|
# Comma-separated allowed Google OAuth client IDs
|
||||||
|
GOOGLE_CLIENT_ID=web-client-id.apps.googleusercontent.com,android-client-id.apps.googleusercontent.com
|
||||||
# Cấu hình NextAuth
|
CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
|
||||||
NEXTAUTH_SECRET="your-super-secret-key"
|
APP_ENV=development
|
||||||
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
|
## Dev Setup (UV)
|
||||||
|
|
||||||
|
1. Install UV
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Cài đặt các gói thư viện
|
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
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
|
2. Sync dependencies
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm dev
|
uv sync
|
||||||
```
|
```
|
||||||
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).
|
|
||||||
|
|
||||||
---
|
3. Run API in dev mode
|
||||||
|
|
||||||
## 🏗 Hướng dẫn Build
|
|
||||||
|
|
||||||
Để build project cho môi trường production:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Đảm bảo Prisma Client đã được generate
|
uv run uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||||
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:
|
4. Verify health
|
||||||
```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
|
```bash
|
||||||
# Build và chạy ngầm (detached mode)
|
curl http://localhost:8000/api/health
|
||||||
docker-compose up -d --build
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Để xem log của container:
|
## Docker Compose
|
||||||
|
|
||||||
|
### Production-style API only (external DBs)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose logs -f web
|
docker compose up -d --build api
|
||||||
```
|
```
|
||||||
Dừng và xóa container:
|
|
||||||
|
### Full local stack (API + Postgres + Mongo)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose down
|
docker compose --profile localdb up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Implemented Endpoints
|
||||||
|
|
||||||
|
- GET /api/health
|
||||||
|
- POST /api/auth/mobile-login
|
||||||
|
- GET /api/user/profile
|
||||||
|
- GET/POST /api/user/bookmarks
|
||||||
|
- DELETE /api/user/bookmarks/{novelId}
|
||||||
|
- POST /api/user/reading-progress
|
||||||
|
- GET/POST /api/user/settings
|
||||||
|
- GET/POST/DELETE /api/user/recommendations
|
||||||
|
- GET /api/genres
|
||||||
|
- GET /api/novels/browse
|
||||||
|
- GET /api/novels/{idOrSlug}
|
||||||
|
- GET /api/truyen/{id}/chapters
|
||||||
|
- GET/POST /api/truyen/{id}/comments
|
||||||
|
- POST /api/truyen/{id}/rate
|
||||||
|
- GET /api/truyen/suggest
|
||||||
|
- GET /api/chapters/{chapterId}
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Web session auth is supported via NextAuth session cookies (next-auth.session-token and secure variants).
|
||||||
|
- Mobile auth is supported via Bearer JWT from /api/auth/mobile-login.
|
||||||
|
|||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, Request
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.database import get_db_session
|
||||||
|
|
||||||
|
SESSION_COOKIE_KEYS = [
|
||||||
|
"next-auth.session-token",
|
||||||
|
"__Secure-next-auth.session-token",
|
||||||
|
"authjs.session-token",
|
||||||
|
"__Secure-authjs.session-token",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _jwt_secret() -> str:
|
||||||
|
return settings.mobile_jwt_secret or settings.nextauth_secret
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(user_id: str) -> str:
|
||||||
|
now = dt.datetime.now(dt.timezone.utc)
|
||||||
|
payload = {
|
||||||
|
"sub": user_id,
|
||||||
|
"iat": int(now.timestamp()),
|
||||||
|
"exp": int((now + dt.timedelta(days=7)).timestamp()),
|
||||||
|
}
|
||||||
|
secret = _jwt_secret()
|
||||||
|
if not secret:
|
||||||
|
raise RuntimeError("Missing MOBILE_JWT_SECRET or NEXTAUTH_SECRET")
|
||||||
|
return jwt.encode(payload, secret, algorithm="HS256")
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_user_by_id(db: AsyncSession, user_id: str) -> dict[str, Any] | None:
|
||||||
|
result = await db.execute(
|
||||||
|
text(
|
||||||
|
'SELECT id, email, name, image, role FROM "User" WHERE id = :user_id LIMIT 1'
|
||||||
|
),
|
||||||
|
{"user_id": user_id},
|
||||||
|
)
|
||||||
|
row = result.mappings().first()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_user_from_session_cookie(db: AsyncSession, request: Request) -> dict[str, Any] | None:
|
||||||
|
token = None
|
||||||
|
for key in SESSION_COOKIE_KEYS:
|
||||||
|
value = request.cookies.get(key)
|
||||||
|
if value:
|
||||||
|
token = value
|
||||||
|
break
|
||||||
|
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
text(
|
||||||
|
'SELECT u.id, u.email, u.name, u.image, u.role '
|
||||||
|
'FROM "Session" s '
|
||||||
|
'JOIN "User" u ON u.id = s."userId" '
|
||||||
|
'WHERE s."sessionToken" = :token AND s.expires > NOW() '
|
||||||
|
'LIMIT 1'
|
||||||
|
),
|
||||||
|
{"token": token},
|
||||||
|
)
|
||||||
|
row = result.mappings().first()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_current_user(db: AsyncSession, request: Request) -> dict[str, Any] | None:
|
||||||
|
auth = request.headers.get("authorization", "")
|
||||||
|
if auth.lower().startswith("bearer "):
|
||||||
|
token = auth.split(" ", 1)[1].strip()
|
||||||
|
secret = _jwt_secret()
|
||||||
|
if not secret:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, secret, algorithms=["HS256"])
|
||||||
|
subject = payload.get("sub")
|
||||||
|
if isinstance(subject, str) and subject:
|
||||||
|
return await _get_user_by_id(db, subject)
|
||||||
|
except JWTError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return await _get_user_from_session_cookie(db, request)
|
||||||
|
|
||||||
|
|
||||||
|
async def require_current_user(db: AsyncSession, request: Request) -> dict[str, Any]:
|
||||||
|
user = await resolve_current_user(db, request)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def require_mod_user(request: Request, db: AsyncSession = Depends(get_db_session)) -> dict[str, Any]:
|
||||||
|
user = await resolve_current_user(db, request)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||||
|
if user.get("role") not in ("MOD", "ADMIN"):
|
||||||
|
raise HTTPException(status_code=403, detail="Forbidden: MOD or ADMIN role required")
|
||||||
|
return user
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||||
|
|
||||||
|
app_name: str = "reader-api"
|
||||||
|
app_env: str = "development"
|
||||||
|
|
||||||
|
database_url: str
|
||||||
|
mongodb_uri: str
|
||||||
|
|
||||||
|
google_client_id: str = ""
|
||||||
|
nextauth_secret: str = ""
|
||||||
|
mobile_jwt_secret: str = ""
|
||||||
|
|
||||||
|
cors_origins: str = "*"
|
||||||
|
r2_account_id: str = ""
|
||||||
|
r2_access_key_id: str = ""
|
||||||
|
r2_secret_access_key: str = ""
|
||||||
|
r2_bucket_name: str = ""
|
||||||
|
r2_public_base_url: str = ""
|
||||||
|
|
||||||
|
deepseek_key: str = ""
|
||||||
|
deepseek_model: str = "deepseek-chat"
|
||||||
|
openrouter_key: str = ""
|
||||||
|
openrouter_paused: str = "true"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def google_client_id_list(self) -> list[str]:
|
||||||
|
raw = (self.google_client_id or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return []
|
||||||
|
return [item.strip() for item in raw.split(",") if item.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cors_origin_list(self) -> list[str]:
|
||||||
|
raw = (self.cors_origins or "*").strip()
|
||||||
|
if raw == "*":
|
||||||
|
return ["*"]
|
||||||
|
return [item.strip() for item in raw.split(",") if item.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
from motor.motor_asyncio import AsyncIOMotorClient
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_database_url(url: str) -> str:
|
||||||
|
# Strip Prisma-only query params (e.g. ?schema=public) that asyncpg doesn't accept
|
||||||
|
from urllib.parse import urlparse, urlencode, parse_qsl, urlunparse
|
||||||
|
|
||||||
|
_PRISMA_ONLY_PARAMS = {"schema", "connection_limit", "pool_timeout", "connect_timeout", "sslmode"}
|
||||||
|
|
||||||
|
if url.startswith("postgresql+asyncpg://"):
|
||||||
|
scheme = "postgresql+asyncpg"
|
||||||
|
elif url.startswith("postgresql://") or url.startswith("postgres://"):
|
||||||
|
scheme = "postgresql+asyncpg"
|
||||||
|
else:
|
||||||
|
return url
|
||||||
|
|
||||||
|
parsed = urlparse(url)
|
||||||
|
clean_params = [(k, v) for k, v in parse_qsl(parsed.query) if k not in _PRISMA_ONLY_PARAMS]
|
||||||
|
clean_url = urlunparse((
|
||||||
|
scheme,
|
||||||
|
parsed.netloc,
|
||||||
|
parsed.path,
|
||||||
|
parsed.params,
|
||||||
|
urlencode(clean_params),
|
||||||
|
parsed.fragment,
|
||||||
|
))
|
||||||
|
return clean_url
|
||||||
|
|
||||||
|
|
||||||
|
engine = create_async_engine(_normalize_database_url(settings.database_url), pool_pre_ping=True)
|
||||||
|
SessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
mongo_client = AsyncIOMotorClient(settings.mongodb_uri)
|
||||||
|
mongo_db = mongo_client.get_default_database()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db_session() -> AsyncSession:
|
||||||
|
session = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
finally:
|
||||||
|
await session.close()
|
||||||
+1202
File diff suppressed because it is too large
Load Diff
+2417
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,59 @@
|
|||||||
|
services:
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: reader-api:latest
|
||||||
|
container_name: reader-api
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/health').read()"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 20s
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
mongo:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: reader-api-postgres
|
||||||
|
profiles: ["localdb"]
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: reader
|
||||||
|
POSTGRES_USER: reader
|
||||||
|
POSTGRES_PASSWORD: reader
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U reader -d reader"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
mongo:
|
||||||
|
image: mongo:7
|
||||||
|
container_name: reader-api-mongo
|
||||||
|
profiles: ["localdb"]
|
||||||
|
ports:
|
||||||
|
- "27017:27017"
|
||||||
|
volumes:
|
||||||
|
- mongo_data:/data/db
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
mongo_data:
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
[project]
|
||||||
|
name = "reader-api"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Shared backend API for reader web and reader-app mobile clients"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.116.1",
|
||||||
|
"uvicorn[standard]>=0.35.0",
|
||||||
|
"sqlalchemy[asyncio]>=2.0.43",
|
||||||
|
"greenlet>=3.0.0",
|
||||||
|
"asyncpg>=0.30.0",
|
||||||
|
"motor>=3.7.1",
|
||||||
|
"python-jose[cryptography]>=3.5.0",
|
||||||
|
"google-auth>=2.40.3",
|
||||||
|
"requests>=2.32.5",
|
||||||
|
"pydantic-settings>=2.10.1",
|
||||||
|
# mod routes deps
|
||||||
|
"boto3>=1.35.0",
|
||||||
|
"httpx>=0.27.0",
|
||||||
|
"ebooklib>=0.18",
|
||||||
|
"html2text>=2024.2.26",
|
||||||
|
"python-slugify>=8.0.4",
|
||||||
|
"python-multipart>=0.0.9",
|
||||||
|
]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"ruff>=0.12.11",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "I"]
|
||||||
|
|
||||||
|
[tool.uv]
|
||||||
|
package = false
|
||||||
Reference in New Issue
Block a user