feat: add per-book reading journal notes
Claude-Session: https://claude.ai/code/session_01M9MLit5Ko3X4s7rzC5Kv7X
This commit is contained in:
+75
-2
@@ -1,4 +1,77 @@
|
||||
"""Notes slice: a per-book reading journal."""
|
||||
from flask import Blueprint
|
||||
"""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/<int(max=2147483647):book_id>/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/<int(max=2147483647):book_id>/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/<int(max=2147483647):note_id>")
|
||||
@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/<int(max=2147483647):note_id>")
|
||||
@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
|
||||
|
||||
Reference in New Issue
Block a user