Files
Hyungi Ahn 24bd363beb feat(library): 자료별 손글씨 노트 (PR-D) — iPad 학습 시 옆에 필기
자료실 자료 detail 에 "필기" 버튼 → 본문 아래에 HandwriteCanvas 띄움.
자료당 사용자별 1개 캔버스 (UNIQUE user×document). upsert 방식.

Backend:
- migrations 177~178: document_notes (user_id, document_id, strokes_json,
  canvas 크기) + UNIQUE(user_id, document_id) + 인덱스
- app/models/document_note.py: DocumentNote ORM
- app/api/document_notes.py:
  · GET    /api/documents/{id}/note  — 단건 조회 (없으면 strokes_json=null)
  · PUT    /api/documents/{id}/note  — upsert (PostgreSQL ON CONFLICT)
  · DELETE /api/documents/{id}/note
  · ownership: WHERE user_id=current_user.id (single-user 가정)
- app/main.py: document_notes_router 등록 (/api/documents prefix)

Frontend:
- routes/documents/[id]/+page.svelte:
  · 자료실 자료 (category='library') 의 affordance row 에 "필기" 토글 추가
  · 클릭 시 GET /note 로 strokes 로드 → HandwriteCanvas 본문 카드 아래 마운트
  · 캔버스 onChange → PUT /note 자동 저장 (HandwriteCanvas 내부 3초 idle 디바운스 활용)
  · 60vh / min-h-[400px] 분할. 모바일에선 본문 아래 스크롤로 자연스럽게.
- HandwriteCanvas 재사용 — sessionId prop 에 documentId 전달.
  localStorage 키도 그대로 사용 (자료별로 namespacing).
2026-04-27 12:38:03 +09:00

152 lines
4.6 KiB
Python

"""자료별 손글씨 노트 API.
흐름:
GET /api/documents/{id}/note → 단건 조회 (없으면 strokes_json=None)
PUT /api/documents/{id}/note → upsert (strokes_json + canvas 크기)
DELETE /api/documents/{id}/note → 노트 삭제
ownership:
- documents 에 user_id 부재 (single-user). document_notes.user_id 만으로 분리.
- GET/PUT/DELETE 모두 WHERE user_id=current_user.id AND document_id=:doc_id.
"""
import logging
from datetime import datetime
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from core.database import get_session
from models.document import Document
from models.document_note import DocumentNote
from models.user import User
logger = logging.getLogger(__name__)
router = APIRouter()
class NoteResponse(BaseModel):
document_id: int
strokes_json: dict[str, Any] | None
canvas_width: int | None
canvas_height: int | None
schema_version: int
updated_at: datetime | None
created_at: datetime | None
class NoteUpdate(BaseModel):
strokes_json: dict[str, Any] | None = None
canvas_width: int | None = None
canvas_height: int | None = None
async def _verify_document(session: AsyncSession, document_id: int) -> Document:
doc = await session.get(Document, document_id)
if doc is None or getattr(doc, "deleted_at", None) is not None:
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
return doc
def _empty_response(document_id: int) -> NoteResponse:
return NoteResponse(
document_id=document_id,
strokes_json=None,
canvas_width=None,
canvas_height=None,
schema_version=1,
updated_at=None,
created_at=None,
)
def _to_response(note: DocumentNote) -> NoteResponse:
return NoteResponse(
document_id=note.document_id,
strokes_json=note.strokes_json,
canvas_width=note.canvas_width,
canvas_height=note.canvas_height,
schema_version=note.schema_version,
updated_at=note.updated_at,
created_at=note.created_at,
)
@router.get("/{document_id}/note", response_model=NoteResponse)
async def get_note(
document_id: int,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
await _verify_document(session, document_id)
res = await session.execute(
select(DocumentNote).where(
DocumentNote.user_id == user.id,
DocumentNote.document_id == document_id,
)
)
note = res.scalar_one_or_none()
if note is None:
return _empty_response(document_id)
return _to_response(note)
@router.put("/{document_id}/note", response_model=NoteResponse)
async def upsert_note(
document_id: int,
body: NoteUpdate,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""upsert — 같은 (user, document) 면 update, 없으면 insert. PostgreSQL ON CONFLICT."""
await _verify_document(session, document_id)
values: dict[str, Any] = {
"user_id": user.id,
"document_id": document_id,
"strokes_json": body.strokes_json,
"canvas_width": body.canvas_width,
"canvas_height": body.canvas_height,
}
stmt = (
pg_insert(DocumentNote)
.values(**values)
.on_conflict_do_update(
index_elements=["user_id", "document_id"],
set_={
"strokes_json": body.strokes_json,
"canvas_width": body.canvas_width,
"canvas_height": body.canvas_height,
"updated_at": datetime.now(),
},
)
.returning(DocumentNote)
)
result = await session.execute(stmt)
note = result.scalar_one()
await session.commit()
return _to_response(note)
@router.delete("/{document_id}/note", status_code=204)
async def delete_note(
document_id: int,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
await _verify_document(session, document_id)
res = await session.execute(
select(DocumentNote).where(
DocumentNote.user_id == user.id,
DocumentNote.document_id == document_id,
)
)
note = res.scalar_one_or_none()
if note is not None:
await session.delete(note)
await session.commit()