"""Notes slice: a per-book reading journal. Ownership is always derived through books.user_id.""" from flask import Blueprint, g, jsonify from auth import login_required from db import query, query_one from validation import ApiError, json_body, text bp = Blueprint("notes", __name__, url_prefix="/api") MAX_BODY = 10_000 NOTE_COLUMNS = "n.id, n.book_id, n.body, n.created_at, n.updated_at" def to_json(row: dict) -> dict: return { "id": row["id"], "book_id": row["book_id"], "body": row["body"], "created_at": row["created_at"].isoformat(), "updated_at": row["updated_at"].isoformat(), } def _require_book(book_id: int) -> None: if ( query_one( "SELECT 1 FROM books WHERE id = %s AND user_id = %s", (book_id, g.user_id) ) is None ): raise ApiError(404, "Book not found") @bp.get("/books//notes") @login_required def list_notes(book_id: int): _require_book(book_id) rows = query( f"SELECT {NOTE_COLUMNS} FROM notes n WHERE n.book_id = %s ORDER BY n.created_at DESC, n.id DESC", (book_id,), ) return jsonify([to_json(row) for row in rows]) @bp.post("/books//notes") @login_required def add_note(book_id: int): _require_book(book_id) body = text(json_body({"body"}), "body", MAX_BODY) row = query_one( f"INSERT INTO notes AS n (book_id, body) VALUES (%s, %s) RETURNING {NOTE_COLUMNS}", (book_id, body), ) return jsonify(to_json(row)), 201 @bp.patch("/notes/") @login_required def update_note(note_id: int): body = text(json_body({"body"}), "body", MAX_BODY) row = query_one( f""" UPDATE notes n SET body = %s, updated_at = now() FROM books b WHERE n.id = %s AND b.id = n.book_id AND b.user_id = %s RETURNING {NOTE_COLUMNS} """, (body, note_id, g.user_id), ) if row is None: raise ApiError(404, "Note not found") return jsonify(to_json(row)) @bp.delete("/notes/") @login_required def delete_note(note_id: int): row = query_one( "DELETE FROM notes n USING books b WHERE n.id = %s AND b.id = n.book_id AND b.user_id = %s RETURNING n.id", (note_id, g.user_id), ) if row is None: raise ApiError(404, "Note not found") return "", 204