"""자료별 손글씨 노트 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()