24bd363beb
자료실 자료 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).
152 lines
4.6 KiB
Python
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()
|