From df59a30cca106aa22ed389dc5bb745dad836282b Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 19 Jul 2026 15:59:43 -0400 Subject: [PATCH] M1: notes model + migration 0002 + notes API (CRUD, trash/restore) - Note model (owner_id, title, body, color-key, pinned, archived, deleted_at soft-delete, timestamps) + board index; NOTE_COLORS palette keys. - Migration 0002 (notes table + ix_notes_owner_board). - /api/notes blueprint (login_required): list (?filter=active|archived|trash, pinned-then-updated, read via visible_to_user ACL), create, get, patch (title/body/color/pinned/archived), trash, restore, permanent delete (trash-only). Mutations owner-scoped; empty note rejected (400). - DB-free unit tests: is_empty_note, normalize_color, palette, serialize, auth-guard on list/create. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm --- alembic/versions/0002_notes.py | 38 ++++++++ src/thoughtsync/app.py | 2 + src/thoughtsync/models/all.py | 2 +- src/thoughtsync/models/note.py | 63 +++++++++++++ src/thoughtsync/notes.py | 161 +++++++++++++++++++++++++++++++++ tests/test_notes.py | 52 +++++++++++ 6 files changed, 317 insertions(+), 1 deletion(-) create mode 100644 alembic/versions/0002_notes.py create mode 100644 src/thoughtsync/models/note.py create mode 100644 src/thoughtsync/notes.py create mode 100644 tests/test_notes.py diff --git a/alembic/versions/0002_notes.py b/alembic/versions/0002_notes.py new file mode 100644 index 0000000..e246e1b --- /dev/null +++ b/alembic/versions/0002_notes.py @@ -0,0 +1,38 @@ +"""notes + +Revision ID: 0002 +Revises: 0001 +Create Date: 2026-07-19 + +The M1 capture core: the notes table the masonry board renders. +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID + +revision = "0002" +down_revision = "0001" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "notes", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("owner_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("title", sa.Text(), nullable=True), + sa.Column("body", sa.Text(), nullable=False, server_default=""), + sa.Column("color", sa.Text(), nullable=False, server_default="default"), + sa.Column("pinned", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("archived", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + ) + op.create_index("ix_notes_owner_board", "notes", ["owner_id", "deleted_at", "archived", "pinned"]) + + +def downgrade() -> None: + op.drop_index("ix_notes_owner_board", table_name="notes") + op.drop_table("notes") diff --git a/src/thoughtsync/app.py b/src/thoughtsync/app.py index 0e7cb71..0c8551d 100644 --- a/src/thoughtsync/app.py +++ b/src/thoughtsync/app.py @@ -7,6 +7,7 @@ from quart import Quart, jsonify, send_from_directory from . import __version__ from .auth import bp as auth_bp from .config import Config +from .notes import bp as notes_bp STATIC_DIR = os.path.join(os.path.dirname(__file__), "static") @@ -19,6 +20,7 @@ def create_app() -> Quart: app.config["APP_VERSION"] = os.environ.get("APP_VERSION", __version__) app.register_blueprint(auth_bp) + app.register_blueprint(notes_bp) @app.get("/api/health") async def health(): diff --git a/src/thoughtsync/models/all.py b/src/thoughtsync/models/all.py index 265278f..0e60076 100644 --- a/src/thoughtsync/models/all.py +++ b/src/thoughtsync/models/all.py @@ -3,4 +3,4 @@ Imported for side effects only (model registration on Base.metadata). """ -from . import group, share, user # noqa: F401 +from . import group, note, share, user # noqa: F401 diff --git a/src/thoughtsync/models/note.py b/src/thoughtsync/models/note.py new file mode 100644 index 0000000..93904b9 --- /dev/null +++ b/src/thoughtsync/models/note.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Text, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from . import Base + +# The Keep-style palette. Stored as a key string, so the actual tints live in the +# frontend and can change without a schema migration. +NOTE_COLORS = { + "default", + "red", + "orange", + "yellow", + "green", + "teal", + "blue", + "purple", + "pink", + "gray", +} + + +class Note(Base): + __tablename__ = "notes" + __table_args__ = ( + # Covers the board queries: a user's notes filtered by trash/archive state, + # pinned first. + Index("ix_notes_owner_board", "owner_id", "deleted_at", "archived", "pinned"), + ) + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + owner_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + title: Mapped[str | None] = mapped_column(Text(), nullable=True) + body: Mapped[str] = mapped_column(Text(), nullable=False, server_default="") + color: Mapped[str] = mapped_column(Text(), nullable=False, server_default="default") + pinned: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default=func.false()) + archived: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default=func.false()) + # Soft delete: non-null => in Trash. Restore sets it back to null. + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now() + ) + + def serialize(self) -> dict: + return { + "id": str(self.id), + "title": self.title, + "body": self.body, + "color": self.color, + "pinned": self.pinned, + "archived": self.archived, + "trashed": self.deleted_at is not None, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + } diff --git a/src/thoughtsync/notes.py b/src/thoughtsync/notes.py new file mode 100644 index 0000000..cbd3012 --- /dev/null +++ b/src/thoughtsync/notes.py @@ -0,0 +1,161 @@ +from __future__ import annotations + +import uuid +from datetime import datetime, timezone + +from quart import Blueprint, g, jsonify, request +from sqlalchemy import select + +from .acl import visible_to_user +from .auth import login_required +from .db import session_scope +from .models.note import NOTE_COLORS, Note + +bp = Blueprint("notes", __name__, url_prefix="/api/notes") + +VALID_FILTERS = {"active", "archived", "trash"} + + +def is_empty_note(title: str | None, body: str | None) -> bool: + return not (title or "").strip() and not (body or "").strip() + + +def normalize_color(color: object) -> str: + return color if color in NOTE_COLORS else "default" + + +def apply_filter(stmt, filter_name: str): + """Narrow a notes query to one board view. `active` = live board (not trashed, + not archived); `archived` = archived but not trashed; `trash` = trashed.""" + if filter_name == "archived": + return stmt.where(Note.deleted_at.is_(None), Note.archived.is_(True)) + if filter_name == "trash": + return stmt.where(Note.deleted_at.is_not(None)) + return stmt.where(Note.deleted_at.is_(None), Note.archived.is_(False)) + + +async def _get_owned(db, note_id: str) -> Note | None: + """Fetch a note the current user OWNS (mutations are owner-only in M1; share + write-permissions arrive with the sharing UI in a later milestone).""" + try: + nid = uuid.UUID(note_id) + except (ValueError, TypeError): + return None + return await db.scalar(select(Note).where(Note.id == nid, Note.owner_id == g.user_id)) + + +@bp.get("") +@login_required +async def list_notes(): + filter_name = request.args.get("filter", "active") + if filter_name not in VALID_FILTERS: + return jsonify({"error": "invalid filter"}), 400 + async with session_scope() as db: + # Read via the ACL predicate (owner OR shared) so shared notes appear for + # free once sharing lands (rule 47). With no shares yet this is owner-only. + stmt = select(Note).where(visible_to_user("note", Note.owner_id, Note.id, g.user_id)) + stmt = apply_filter(stmt, filter_name) + stmt = stmt.order_by(Note.pinned.desc(), Note.updated_at.desc()) + notes = (await db.scalars(stmt)).all() + return jsonify({"notes": [n.serialize() for n in notes]}) + + +@bp.post("") +@login_required +async def create_note(): + data = await request.get_json(silent=True) or {} + title = data.get("title") if isinstance(data.get("title"), str) else "" + body = data.get("body") if isinstance(data.get("body"), str) else "" + if is_empty_note(title, body): + return jsonify({"error": "note is empty"}), 400 + async with session_scope() as db: + note = Note( + owner_id=g.user_id, + title=title.strip() or None, + body=body, + color=normalize_color(data.get("color")), + ) + db.add(note) + await db.commit() + await db.refresh(note) + return jsonify(note.serialize()), 201 + + +@bp.get("/") +@login_required +async def get_note(note_id: str): + try: + nid = uuid.UUID(note_id) + except (ValueError, TypeError): + return jsonify({"error": "not found"}), 404 + async with session_scope() as db: + note = await db.scalar( + select(Note).where(Note.id == nid, visible_to_user("note", Note.owner_id, Note.id, g.user_id)) + ) + if note is None: + return jsonify({"error": "not found"}), 404 + return jsonify(note.serialize()) + + +@bp.patch("/") +@login_required +async def update_note(note_id: str): + data = await request.get_json(silent=True) or {} + async with session_scope() as db: + note = await _get_owned(db, note_id) + if note is None: + return jsonify({"error": "not found"}), 404 + if "title" in data: + title = data["title"] if isinstance(data["title"], str) else "" + note.title = title.strip() or None + if "body" in data and isinstance(data["body"], str): + note.body = data["body"] + if "color" in data: + note.color = normalize_color(data["color"]) + if "pinned" in data: + note.pinned = bool(data["pinned"]) + if "archived" in data: + note.archived = bool(data["archived"]) + await db.commit() + await db.refresh(note) + return jsonify(note.serialize()) + + +@bp.post("//trash") +@login_required +async def trash_note(note_id: str): + async with session_scope() as db: + note = await _get_owned(db, note_id) + if note is None: + return jsonify({"error": "not found"}), 404 + note.deleted_at = datetime.now(timezone.utc) + await db.commit() + await db.refresh(note) + return jsonify(note.serialize()) + + +@bp.post("//restore") +@login_required +async def restore_note(note_id: str): + async with session_scope() as db: + note = await _get_owned(db, note_id) + if note is None: + return jsonify({"error": "not found"}), 404 + note.deleted_at = None + await db.commit() + await db.refresh(note) + return jsonify(note.serialize()) + + +@bp.delete("/") +@login_required +async def delete_note(note_id: str): + async with session_scope() as db: + note = await _get_owned(db, note_id) + if note is None: + return jsonify({"error": "not found"}), 404 + if note.deleted_at is None: + return jsonify({"error": "note must be trashed before permanent delete"}), 409 + await db.delete(note) + await db.commit() + return jsonify({"ok": True}) diff --git a/tests/test_notes.py b/tests/test_notes.py new file mode 100644 index 0000000..6620b9c --- /dev/null +++ b/tests/test_notes.py @@ -0,0 +1,52 @@ +import pytest + +from thoughtsync.app import create_app +from thoughtsync.models.note import NOTE_COLORS, Note +from thoughtsync.notes import is_empty_note, normalize_color + + +@pytest.fixture +def app(): + return create_app() + + +def test_is_empty_note(): + assert is_empty_note(None, None) + assert is_empty_note("", " ") + assert not is_empty_note("title", "") + assert not is_empty_note("", "body") + + +def test_normalize_color(): + assert normalize_color("blue") == "blue" + assert normalize_color("chartreuse") == "default" + assert normalize_color(None) == "default" + assert normalize_color(123) == "default" + + +def test_palette_has_core_colors(): + for c in ("default", "red", "orange", "yellow", "green", "teal", "blue", "purple", "pink", "gray"): + assert c in NOTE_COLORS + + +def test_serialize_shape(): + n = Note(title="t", body="b", color="blue", pinned=True, archived=False) + s = n.serialize() + assert s["title"] == "t" + assert s["body"] == "b" + assert s["color"] == "blue" + assert s["pinned"] is True + assert s["archived"] is False + assert s["trashed"] is False + + +async def test_notes_list_requires_auth(app): + client = app.test_client() + resp = await client.get("/api/notes") + assert resp.status_code == 401 + + +async def test_notes_create_requires_auth(app): + client = app.test_client() + resp = await client.post("/api/notes", json={"body": "hi"}) + assert resp.status_code == 401