feat: auth by default, theme toggle, CoderPad startup, review fixes

- Require a session on every route; public routes opt out with @allow_anonymous
- Split password hashing and pepper loading into passwords.py
- Add a system/light/dark theme toggle backed by light-dark() colors
- Ignore stale 401s from an earlier session, PATCH only changed book fields,
  and block overlapping journal-entry saves
- Add bin/start and CoderPad Vite server settings for the pad's start/restart
- Rewrite README as a mise onboarding guide; expand .gitignore
- Include review-round fixes and tests
This commit is contained in:
2026-10-02 16:49:14 -05:00
parent 9d7b0e805c
commit 7f5d034a1f
46 changed files with 1797 additions and 497 deletions
+26 -18
View File
@@ -1,9 +1,12 @@
"""Notes slice: a per-book reading journal. Ownership is always derived through books.user_id."""
from flask import Blueprint, g, jsonify
from datetime import datetime
from typing import TypedDict, cast
from auth import login_required
from db import query, query_one
from flask import Blueprint, Response, g, jsonify
from flask.typing import ResponseReturnValue
from db import Row, query, query_one, query_row
from validation import ApiError, json_body, text
bp = Blueprint("notes", __name__, url_prefix="/api")
@@ -12,13 +15,22 @@ 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:
class NoteRow(TypedDict):
id: int
book_id: int
body: str
created_at: datetime
updated_at: datetime
def to_json(row: Row) -> dict[str, object]:
note = cast(NoteRow, row)
return {
"id": row["id"],
"book_id": row["book_id"],
"body": row["body"],
"created_at": row["created_at"].isoformat(),
"updated_at": row["updated_at"].isoformat(),
"id": note["id"],
"book_id": note["book_id"],
"body": note["body"],
"created_at": note["created_at"].isoformat(),
"updated_at": note["updated_at"].isoformat(),
}
@@ -33,8 +45,7 @@ def _require_book(book_id: int) -> None:
@bp.get("/books/<int(max=2147483647):book_id>/notes")
@login_required
def list_notes(book_id: int):
def list_notes(book_id: int) -> Response:
_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",
@@ -44,11 +55,10 @@ def list_notes(book_id: int):
@bp.post("/books/<int(max=2147483647):book_id>/notes")
@login_required
def add_note(book_id: int):
def add_note(book_id: int) -> ResponseReturnValue:
_require_book(book_id)
body = text(json_body({"body"}), "body", MAX_BODY)
row = query_one(
row = query_row(
f"INSERT INTO notes AS n (book_id, body) VALUES (%s, %s) RETURNING {NOTE_COLUMNS}",
(book_id, body),
)
@@ -56,8 +66,7 @@ def add_note(book_id: int):
@bp.patch("/notes/<int(max=2147483647):note_id>")
@login_required
def update_note(note_id: int):
def update_note(note_id: int) -> Response:
body = text(json_body({"body"}), "body", MAX_BODY)
row = query_one(
f"""
@@ -75,8 +84,7 @@ def update_note(note_id: int):
@bp.delete("/notes/<int(max=2147483647):note_id>")
@login_required
def delete_note(note_id: int):
def delete_note(note_id: int) -> ResponseReturnValue:
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),