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})