diff --git a/app/api/document_notes.py b/app/api/document_notes.py new file mode 100644 index 0000000..b15f6bf --- /dev/null +++ b/app/api/document_notes.py @@ -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() diff --git a/app/main.py b/app/main.py index 4f01cf0..c1055ad 100644 --- a/app/main.py +++ b/app/main.py @@ -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"]) diff --git a/app/models/document_note.py b/app/models/document_note.py new file mode 100644 index 0000000..d14a538 --- /dev/null +++ b/app/models/document_note.py @@ -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 + ) diff --git a/frontend/src/routes/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte index 20c637f..2d47119 100644 --- a/frontend/src/routes/documents/[id]/+page.svelte +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -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 @@ + {#if doc.category === 'library'} + + {/if} @@ -227,6 +267,19 @@ /> {/if} + + + {#if noteOpen && doc.category === 'library' && noteLoaded} + +
+ saveNote(strokes)} + /> +
+
+ {/if} diff --git a/migrations/177_document_notes.sql b/migrations/177_document_notes.sql new file mode 100644 index 0000000..614e2f9 --- /dev/null +++ b/migrations/177_document_notes.sql @@ -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) +); diff --git a/migrations/178_document_notes_idx.sql b/migrations/178_document_notes_idx.sql new file mode 100644 index 0000000..9eba5d3 --- /dev/null +++ b/migrations/178_document_notes_idx.sql @@ -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);