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).
This commit is contained in:
Hyungi Ahn
2026-04-27 12:38:03 +09:00
parent 877a5f79d1
commit 24bd363beb
6 changed files with 278 additions and 1 deletions
+151
View File
@@ -0,0 +1,151 @@
"""자료별 손글씨 노트 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()
+2
View File
@@ -11,6 +11,7 @@ from api.auth import router as auth_router
from api.config import router as config_router
from api.dashboard import router as dashboard_router
from api.digest import router as digest_router
from api.document_notes import router as document_notes_router
from api.document_reads import router as document_reads_router
from api.documents import router as documents_router
from api.library import router as library_router
@@ -103,6 +104,7 @@ app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
app.include_router(documents_router, prefix="/api/documents", tags=["documents"])
# 회독 카운트 — /api/documents/{id}/read* 경로. documents_router 와 prefix 같아 충돌 없음.
app.include_router(document_reads_router, prefix="/api/documents", tags=["document-reads"])
app.include_router(document_notes_router, prefix="/api/documents", tags=["document-notes"])
app.include_router(search_router, prefix="/api/search", tags=["search"])
app.include_router(memos_router, prefix="/api/memos", tags=["memos"])
+44
View File
@@ -0,0 +1,44 @@
"""document_notes 테이블 ORM — 자료별 손글씨 노트 (자료 1:1).
설계:
- user×document UNIQUE — 자료당 사용자별 한 캔버스.
- upsert 방식. PUT /api/documents/{id}/note 로 strokes_json 전체 갱신.
- 회독 (document_reads, append-only log) 와 별개.
NOTE: documents 에 user_id 부재 (single-user). document_notes.user_id 로
ownership. multi-user 전환 시 documents.user_id 추가 후 별도 check 필요.
"""
from datetime import datetime
from typing import Any
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, UniqueConstraint
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from core.database import Base
class DocumentNote(Base):
__tablename__ = "document_notes"
__table_args__ = (
UniqueConstraint("user_id", "document_id", name="document_notes_user_id_document_id_key"),
)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
user_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
document_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("documents.id", ondelete="CASCADE"), nullable=False
)
strokes_json: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
canvas_width: Mapped[int | None] = mapped_column(Integer)
canvas_height: Mapped[int | None] = mapped_column(Integer)
schema_version: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now, nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now, onupdate=datetime.now, nullable=False
)
@@ -9,11 +9,12 @@
import { addToast } from '$lib/stores/toast';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import { ExternalLink, Download, Link2, FileText } from 'lucide-svelte';
import { ExternalLink, Download, Link2, FileText, PenLine, X } from 'lucide-svelte';
import Button from '$lib/components/ui/Button.svelte';
import Card from '$lib/components/ui/Card.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Skeleton from '$lib/components/ui/Skeleton.svelte';
import HandwriteCanvas from '$lib/components/HandwriteCanvas.svelte';
import NoteEditor from '$lib/components/editors/NoteEditor.svelte';
import EditUrlEditor from '$lib/components/editors/EditUrlEditor.svelte';
import TagsEditor from '$lib/components/editors/TagsEditor.svelte';
@@ -42,6 +43,35 @@
let docId = $derived($page.params.id);
// 손글씨 노트 (자료별 1:1) — "필기" 토글 시 사이드 캔버스 띄움.
let noteOpen = $state(false);
let noteStrokes = $state(null); // { version, strokes }
let noteLoaded = $state(false);
async function ensureNoteLoaded() {
if (noteLoaded) return;
try {
const r = await api(`/documents/${docId}/note`);
noteStrokes = r.strokes_json && r.strokes_json.strokes ? r.strokes_json : { version: 1, strokes: [] };
} catch {
noteStrokes = { version: 1, strokes: [] };
}
noteLoaded = true;
}
async function saveNote(strokesJson) {
try {
await api(`/documents/${docId}/note`, {
method: 'PUT',
body: JSON.stringify({ strokes_json: strokesJson }),
});
} catch (err) {
console.warn('필기 저장 실패', err);
}
}
async function toggleNote() {
if (!noteOpen) await ensureNoteLoaded();
noteOpen = !noteOpen;
}
onMount(async () => {
try {
doc = await api(`/documents/${docId}`);
@@ -153,6 +183,16 @@
<Button variant="secondary" size="sm" icon={Link2} onclick={copyLink}>
링크 복사
</Button>
{#if doc.category === 'library'}
<Button
variant={noteOpen ? 'primary' : 'secondary'}
size="sm"
icon={noteOpen ? X : PenLine}
onclick={toggleNote}
>
{noteOpen ? '필기 닫기' : '필기'}
</Button>
{/if}
</div>
<!-- 뷰어 -->
@@ -227,6 +267,19 @@
/>
{/if}
</Card>
<!-- 손글씨 노트 패드 (자료실 자료, "필기" 토글 시) -->
{#if noteOpen && doc.category === 'library' && noteLoaded}
<Card class="overflow-hidden p-0">
<div class="h-[60vh] min-h-[400px] flex flex-col">
<HandwriteCanvas
sessionId={doc.id}
initialStrokes={noteStrokes}
onChange={(strokes) => saveNote(strokes)}
/>
</div>
</Card>
{/if}
</div>
<!-- 오른쪽 (1/3) — editors stack -->
+22
View File
@@ -0,0 +1,22 @@
-- 177_document_notes.sql
-- 자료별 손글씨 노트 — 자료 학습 시 옆에 띄워놓고 필기.
-- plan: ~/.claude/plans/scalable-chasing-stonebraker.md (PR-D)
--
-- 모델: 사용자×자료 1:1. UNIQUE (user_id, document_id) 로 강제.
-- strokes_json 은 perfect-freehand input points + style.
-- canvas_width/height 는 마지막 저장 시점의 캔버스 표시 크기 (참고용, 렌더는 자체 비율 유지).
--
-- 회독 (document_reads) 와 별개 — append-only log 가 아닌 upsert 방식.
CREATE TABLE IF NOT EXISTS document_notes (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
document_id BIGINT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
strokes_json JSONB,
canvas_width INTEGER,
canvas_height INTEGER,
schema_version INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, document_id)
);
+5
View File
@@ -0,0 +1,5 @@
-- 178_document_notes_idx.sql (2/2)
-- (user_id, document_id) UNIQUE 제약이 자체 인덱스 생성하지만 명시 추가 — 조회 단순화.
CREATE INDEX IF NOT EXISTS idx_document_notes_user_doc
ON document_notes (user_id, document_id);