🎉 Initial commit: Document Server MVP

 Features implemented:
- FastAPI backend with JWT authentication
- PostgreSQL database with async SQLAlchemy
- HTML document viewer with smart highlighting
- Note system connected to highlights (1:1 relationship)
- Bookmark system for quick navigation
- Integrated search (documents + notes)
- Tag system for document organization
- Docker containerization with Nginx

🔧 Technical stack:
- Backend: FastAPI + PostgreSQL + Redis
- Frontend: Alpine.js + Tailwind CSS
- Authentication: JWT tokens
- File handling: HTML + PDF support
- Search: Full-text search with relevance scoring

📋 Core functionality:
- Text selection → Highlight creation
- Highlight → Note attachment
- Note management with search/filtering
- Bookmark creation at scroll positions
- Document upload with metadata
- User management (admin creates accounts)
This commit is contained in:
Hyungi Ahn
2025-08-21 16:09:17 +09:00
commit 3036b8f0fb
40 changed files with 6303 additions and 0 deletions

View File

@@ -0,0 +1,300 @@
"""
책갈피 관리 API 라우터
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete, and_
from sqlalchemy.orm import joinedload
from typing import List, Optional
from datetime import datetime
from src.core.database import get_db
from src.models.user import User
from src.models.document import Document
from src.models.bookmark import Bookmark
from src.api.dependencies import get_current_active_user
from pydantic import BaseModel
class CreateBookmarkRequest(BaseModel):
"""책갈피 생성 요청"""
document_id: str
title: str
description: Optional[str] = None
page_number: Optional[int] = None
scroll_position: int = 0
element_id: Optional[str] = None
element_selector: Optional[str] = None
class UpdateBookmarkRequest(BaseModel):
"""책갈피 업데이트 요청"""
title: Optional[str] = None
description: Optional[str] = None
page_number: Optional[int] = None
scroll_position: Optional[int] = None
element_id: Optional[str] = None
element_selector: Optional[str] = None
class BookmarkResponse(BaseModel):
"""책갈피 응답"""
id: str
document_id: str
title: str
description: Optional[str]
page_number: Optional[int]
scroll_position: int
element_id: Optional[str]
element_selector: Optional[str]
created_at: datetime
updated_at: Optional[datetime]
document_title: str
class Config:
from_attributes = True
router = APIRouter()
@router.post("/", response_model=BookmarkResponse)
async def create_bookmark(
bookmark_data: CreateBookmarkRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""책갈피 생성"""
# 문서 존재 및 권한 확인
result = await db.execute(select(Document).where(Document.id == bookmark_data.document_id))
document = result.scalar_one_or_none()
if not document:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Document not found"
)
# 문서 접근 권한 확인
if not document.is_public and document.uploaded_by != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions to access this document"
)
# 책갈피 생성
bookmark = Bookmark(
user_id=current_user.id,
document_id=bookmark_data.document_id,
title=bookmark_data.title,
description=bookmark_data.description,
page_number=bookmark_data.page_number,
scroll_position=bookmark_data.scroll_position,
element_id=bookmark_data.element_id,
element_selector=bookmark_data.element_selector
)
db.add(bookmark)
await db.commit()
await db.refresh(bookmark)
# 응답 데이터 생성
response_data = BookmarkResponse.from_orm(bookmark)
response_data.document_title = document.title
return response_data
@router.get("/", response_model=List[BookmarkResponse])
async def list_user_bookmarks(
skip: int = 0,
limit: int = 50,
document_id: Optional[str] = None,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""사용자의 모든 책갈피 조회"""
query = (
select(Bookmark)
.options(joinedload(Bookmark.document))
.where(Bookmark.user_id == current_user.id)
)
if document_id:
query = query.where(Bookmark.document_id == document_id)
query = query.order_by(Bookmark.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(query)
bookmarks = result.scalars().all()
# 응답 데이터 변환
response_data = []
for bookmark in bookmarks:
bookmark_data = BookmarkResponse.from_orm(bookmark)
bookmark_data.document_title = bookmark.document.title
response_data.append(bookmark_data)
return response_data
@router.get("/document/{document_id}", response_model=List[BookmarkResponse])
async def get_document_bookmarks(
document_id: str,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""특정 문서의 책갈피 목록 조회"""
# 문서 존재 및 권한 확인
result = await db.execute(select(Document).where(Document.id == document_id))
document = result.scalar_one_or_none()
if not document:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Document not found"
)
# 문서 접근 권한 확인
if not document.is_public and document.uploaded_by != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions to access this document"
)
# 사용자의 책갈피만 조회
result = await db.execute(
select(Bookmark)
.options(joinedload(Bookmark.document))
.where(
and_(
Bookmark.document_id == document_id,
Bookmark.user_id == current_user.id
)
)
.order_by(Bookmark.page_number, Bookmark.scroll_position)
)
bookmarks = result.scalars().all()
# 응답 데이터 변환
response_data = []
for bookmark in bookmarks:
bookmark_data = BookmarkResponse.from_orm(bookmark)
bookmark_data.document_title = bookmark.document.title
response_data.append(bookmark_data)
return response_data
@router.get("/{bookmark_id}", response_model=BookmarkResponse)
async def get_bookmark(
bookmark_id: str,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""책갈피 상세 조회"""
result = await db.execute(
select(Bookmark)
.options(joinedload(Bookmark.document))
.where(Bookmark.id == bookmark_id)
)
bookmark = result.scalar_one_or_none()
if not bookmark:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Bookmark not found"
)
# 소유자 확인
if bookmark.user_id != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
response_data = BookmarkResponse.from_orm(bookmark)
response_data.document_title = bookmark.document.title
return response_data
@router.put("/{bookmark_id}", response_model=BookmarkResponse)
async def update_bookmark(
bookmark_id: str,
bookmark_data: UpdateBookmarkRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""책갈피 업데이트"""
result = await db.execute(
select(Bookmark)
.options(joinedload(Bookmark.document))
.where(Bookmark.id == bookmark_id)
)
bookmark = result.scalar_one_or_none()
if not bookmark:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Bookmark not found"
)
# 소유자 확인
if bookmark.user_id != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
# 업데이트
if bookmark_data.title is not None:
bookmark.title = bookmark_data.title
if bookmark_data.description is not None:
bookmark.description = bookmark_data.description
if bookmark_data.page_number is not None:
bookmark.page_number = bookmark_data.page_number
if bookmark_data.scroll_position is not None:
bookmark.scroll_position = bookmark_data.scroll_position
if bookmark_data.element_id is not None:
bookmark.element_id = bookmark_data.element_id
if bookmark_data.element_selector is not None:
bookmark.element_selector = bookmark_data.element_selector
await db.commit()
await db.refresh(bookmark)
response_data = BookmarkResponse.from_orm(bookmark)
response_data.document_title = bookmark.document.title
return response_data
@router.delete("/{bookmark_id}")
async def delete_bookmark(
bookmark_id: str,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""책갈피 삭제"""
result = await db.execute(select(Bookmark).where(Bookmark.id == bookmark_id))
bookmark = result.scalar_one_or_none()
if not bookmark:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Bookmark not found"
)
# 소유자 확인
if bookmark.user_id != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
# 책갈피 삭제
await db.execute(delete(Bookmark).where(Bookmark.id == bookmark_id))
await db.commit()
return {"message": "Bookmark deleted successfully"}