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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
This commit is contained in:
@@ -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")
|
||||||
@@ -7,6 +7,7 @@ from quart import Quart, jsonify, send_from_directory
|
|||||||
from . import __version__
|
from . import __version__
|
||||||
from .auth import bp as auth_bp
|
from .auth import bp as auth_bp
|
||||||
from .config import Config
|
from .config import Config
|
||||||
|
from .notes import bp as notes_bp
|
||||||
|
|
||||||
STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
|
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.config["APP_VERSION"] = os.environ.get("APP_VERSION", __version__)
|
||||||
|
|
||||||
app.register_blueprint(auth_bp)
|
app.register_blueprint(auth_bp)
|
||||||
|
app.register_blueprint(notes_bp)
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
async def health():
|
async def health():
|
||||||
|
|||||||
@@ -3,4 +3,4 @@
|
|||||||
Imported for side effects only (model registration on Base.metadata).
|
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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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("/<note_id>")
|
||||||
|
@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("/<note_id>")
|
||||||
|
@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("/<note_id>/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("/<note_id>/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("/<note_id>")
|
||||||
|
@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})
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user