742 lines
33 KiB
Python
742 lines
33 KiB
Python
# routes/note_routes.py
|
||
"""Google Keep-style notes / checklists API."""
|
||
|
||
import json
|
||
import uuid
|
||
import logging
|
||
from typing import Dict, Any, Optional
|
||
|
||
from fastapi import APIRouter, HTTPException, Request
|
||
from pydantic import BaseModel
|
||
|
||
from core.database import SessionLocal, Note
|
||
from src.auth_helpers import get_current_user
|
||
from sqlalchemy.orm.attributes import flag_modified
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Request models
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class NoteCreate(BaseModel):
|
||
title: str = ""
|
||
content: Optional[str] = None
|
||
items: Optional[list] = None
|
||
note_type: str = "note"
|
||
color: Optional[str] = None
|
||
label: Optional[str] = None
|
||
pinned: bool = False
|
||
due_date: Optional[str] = None
|
||
source: str = "user"
|
||
session_id: Optional[str] = None
|
||
image_url: Optional[str] = None
|
||
repeat: Optional[str] = "none"
|
||
sort_order: Optional[int] = None
|
||
|
||
|
||
class NoteUpdate(BaseModel):
|
||
title: Optional[str] = None
|
||
content: Optional[str] = None
|
||
items: Optional[list] = None
|
||
note_type: Optional[str] = None
|
||
color: Optional[str] = None
|
||
label: Optional[str] = None
|
||
pinned: Optional[bool] = None
|
||
archived: Optional[bool] = None
|
||
due_date: Optional[str] = None
|
||
image_url: Optional[str] = None
|
||
repeat: Optional[str] = None
|
||
sort_order: Optional[int] = None
|
||
agent_session_id: Optional[str] = None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _note_to_dict(note: Note) -> Dict[str, Any]:
|
||
items = None
|
||
if note.items:
|
||
try:
|
||
items = json.loads(note.items)
|
||
except (json.JSONDecodeError, TypeError):
|
||
items = None
|
||
ai_cls = None
|
||
raw_ai = getattr(note, "ai_classification", None)
|
||
if raw_ai:
|
||
try:
|
||
ai_cls = json.loads(raw_ai)
|
||
except (json.JSONDecodeError, TypeError):
|
||
ai_cls = None
|
||
return {
|
||
"id": note.id,
|
||
"owner": note.owner,
|
||
"title": note.title,
|
||
"content": note.content,
|
||
"items": items,
|
||
"note_type": note.note_type,
|
||
"color": note.color,
|
||
"label": note.label,
|
||
"pinned": note.pinned,
|
||
"archived": note.archived,
|
||
"due_date": note.due_date,
|
||
"source": note.source,
|
||
"session_id": note.session_id,
|
||
"sort_order": note.sort_order or 0,
|
||
"image_url": note.image_url,
|
||
"repeat": note.repeat or "none",
|
||
"ai_classification": ai_cls,
|
||
"ai_content_hash": getattr(note, "ai_content_hash", None),
|
||
"agent_session_id": getattr(note, "agent_session_id", None),
|
||
"created_at": note.created_at.isoformat() if note.created_at else None,
|
||
"updated_at": note.updated_at.isoformat() if note.updated_at else None,
|
||
}
|
||
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Reminder dispatch — module-level so background tasks (built-in actions)
|
||
# can call it directly without an HTTP roundtrip + auth cookie. The route
|
||
# version below is a thin wrapper that pulls `owner` from the request.
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# Scheduler reference — set by setup_note_routes() so dispatch_reminder can
|
||
# push a parallel in-app notification (frontend polls the scheduler's queue
|
||
# and fires real browser Notification(...) popups). Optional; works without it.
|
||
_scheduler_ref = None
|
||
|
||
|
||
async def dispatch_reminder(
|
||
title: str,
|
||
note_body: str,
|
||
note_id: str,
|
||
owner: str = "",
|
||
queue_browser: bool = True,
|
||
) -> dict:
|
||
"""Fire a reminder via the configured channel (browser/email/ntfy).
|
||
|
||
Args:
|
||
title: short headline shown to the user
|
||
note_body: longer body text
|
||
note_id: stable id (used as tag/dedupe in browser notifications)
|
||
owner: the user this reminder belongs to — scopes SMTP config to
|
||
their account so we don't cross-leak credentials
|
||
|
||
Returns: {synthesis, email_sent, ntfy_sent}. Browser channel is wired via
|
||
the in-memory notification queue picked up by the frontend poller, so
|
||
nothing is "sent" synchronously for it — the channel just routes there.
|
||
"""
|
||
from src.settings import load_settings
|
||
settings = load_settings()
|
||
channel = settings.get("reminder_channel", "browser")
|
||
llm_on = bool(settings.get("reminder_llm_synthesis", False))
|
||
title = (title or "").strip()
|
||
note_body = (note_body or "").strip()
|
||
cache_key = str(note_id) if note_id else ""
|
||
cache = {}
|
||
cache_path = None
|
||
if cache_key:
|
||
try:
|
||
import json as _json
|
||
from datetime import datetime as _dt, timezone as _tz, timedelta as _td
|
||
from pathlib import Path as _P
|
||
_slug = "".join(c if (c.isalnum() or c in "-_.@") else "_" for c in (owner or "default"))
|
||
cache_path = _P(f"data/note_pings_{_slug}.json")
|
||
if cache_path.exists():
|
||
cache = _json.loads(cache_path.read_text())
|
||
last = cache.get(cache_key)
|
||
if last:
|
||
last_channel = None
|
||
if isinstance(last, dict):
|
||
last_channel = last.get("channel")
|
||
last = last.get("at")
|
||
last_dt = _dt.fromisoformat(str(last))
|
||
if last_dt.tzinfo is None:
|
||
last_dt = last_dt.replace(tzinfo=_tz.utc)
|
||
# Legacy cache values were plain timestamps and could be
|
||
# written by the frontend even when the email/ntfy send failed.
|
||
# Treat those as browser-only dedupe so email reminders can be
|
||
# retried by the backend scanner after a failed frontend path.
|
||
should_skip = last_dt >= _dt.now(_tz.utc) - _td(minutes=25)
|
||
if should_skip and channel in ("email", "ntfy"):
|
||
should_skip = last_channel == channel
|
||
if should_skip:
|
||
return {
|
||
"synthesis": None,
|
||
"email_sent": False,
|
||
"ntfy_sent": False,
|
||
"browser_sent": True,
|
||
"skipped": True,
|
||
}
|
||
except Exception as _e:
|
||
logger.debug(f"dispatch_reminder: cache read failed: {_e}")
|
||
|
||
synthesis = None
|
||
_SYNTH_FAILED_TAG = "[utility model unavailable — no summary generated]"
|
||
if llm_on:
|
||
try:
|
||
from src.endpoint_resolver import resolve_endpoint
|
||
from src.llm_core import llm_call_async
|
||
url, model, headers = resolve_endpoint("utility")
|
||
if not url:
|
||
url, model, headers = resolve_endpoint("default")
|
||
if url and model:
|
||
raw = await llm_call_async(
|
||
url=url, model=model,
|
||
messages=[
|
||
{"role": "system", "content": "You are a reminder assistant. Write a single short, warm, motivating sentence (max 25 words) reminding the user about the note below. Do not add greetings, preamble, or hashtags. Output only the sentence."},
|
||
{"role": "user", "content": f"Title: {title}\n\n{note_body}".strip()},
|
||
],
|
||
temperature=0.7, max_tokens=200, headers=headers, timeout=30,
|
||
)
|
||
from src.text_helpers import strip_think as _strip_think
|
||
# prose=True strips untagged "The user wants me to…" chain-of-thought.
|
||
# prompt_echo=True strips Qwen-style "Thinking Process:" / leaked
|
||
# prompt prefixes. Both are safe here because this is a
|
||
# one-sentence LLM-only output, not user-pasted content.
|
||
synthesis = _strip_think(raw or "", prose=True, prompt_echo=True)
|
||
# Reminder synthesis is supposed to be ONE sentence. Strip-think's
|
||
# paragraph-based heuristic misses cases where the model puts
|
||
# reasoning + answer on consecutive lines inside one paragraph
|
||
# (e.g. "I should write... [\n] You have one task waiting...").
|
||
# Walk lines, drop reasoning/prompt-echo lines, then keep the
|
||
# last surviving line — that's the actual warm sentence.
|
||
if synthesis:
|
||
import re as _re
|
||
# Tightened: target ACTUAL self-talk (model narrating what
|
||
# it'll do) rather than any first-person sentence. The old
|
||
# pattern killed legit warm sentences like "I'll see you
|
||
# tomorrow" or "I should be done by then". New rules:
|
||
# • "I (need|should|have|'ll|will) (write|draft|reply|…)"
|
||
# only matches when followed by a TASK verb taking an
|
||
# OBJECT (so first-person + intransitive verb passes).
|
||
# • Self-instructional patterns the model emits verbatim:
|
||
# "I should write something that reminds them…",
|
||
# "I need to draft…", "Let me think…".
|
||
# • Explicit instructions echoed back from the prompt:
|
||
# "Keep it under 25 words", "No greetings".
|
||
_reasoning = _re.compile(
|
||
r"^\s*(?:"
|
||
# "I should write/draft/compose…" with a task-object follow
|
||
r"i (?:need|should|have|'ll|will|am going|am)\s+to\s+"
|
||
r"(?:write|draft|compose|craft|generate|produce|create|"
|
||
r"summarize|answer|provide|note|address|remind|output)"
|
||
r"\s+(?:a |an |the |something|this|that|here|them|him|her|"
|
||
r"you|user|reply|response|sentence|message|line|warm)|"
|
||
# The model literally narrating about the user
|
||
r"the user (?:wants|is asking|asks|needs|wrote|said|requested) (?:me )?(?:to|for|that|about|something)|"
|
||
# "Let me [think/write/draft/…] (about/for/the …)"
|
||
r"let me (?:think|write|draft|consider|note|see|check)\b\s+(?:about|for|the|this|that|if|whether)|"
|
||
# "Looking at the/this/that …"
|
||
r"looking at (?:the|this|that)\b|"
|
||
# "Based on the/this/what …"
|
||
r"based on (?:the|this|what|context|that)\b|"
|
||
# Prompt-echo of length / style instructions
|
||
r"keep it under \d+ words\b|"
|
||
r"(?:no greetings|no preamble|no hashtags|just output the)\b"
|
||
r").*",
|
||
_re.IGNORECASE,
|
||
)
|
||
# Echo of the prompt's "Pending:" / "<N> pending" tail.
|
||
_echo = _re.compile(
|
||
r"^\s*(?:pending\s*[:.]|(?:\d+|one|two|three|four|five)\s+pending\b)",
|
||
_re.IGNORECASE,
|
||
)
|
||
lines = [ln for ln in synthesis.splitlines() if ln.strip()]
|
||
cleaned = [ln for ln in lines if not _reasoning.match(ln) and not _echo.match(ln)]
|
||
if cleaned:
|
||
# The model's actual answer is normally the LAST surviving
|
||
# line — reasoning leads, answer trails.
|
||
synthesis = cleaned[-1].strip()
|
||
else:
|
||
synthesis = _SYNTH_FAILED_TAG
|
||
except Exception as e:
|
||
logger.warning(f"Reminder LLM synthesis failed: {e}")
|
||
synthesis = _SYNTH_FAILED_TAG
|
||
if synthesis:
|
||
_s = synthesis.strip(); _low = _s.lower()
|
||
if (not _s or _low.startswith("error:") or _low.startswith("[error")
|
||
or "operation failed" in _low
|
||
or ("upstream" in _low and "failed" in _low)) and synthesis != _SYNTH_FAILED_TAG:
|
||
logger.warning(f"Reminder synthesis looked like an error, replacing: {_s[:120]!r}")
|
||
synthesis = _SYNTH_FAILED_TAG
|
||
|
||
email_sent = False
|
||
email_error = ""
|
||
if channel == "email":
|
||
try:
|
||
from routes.email_routes import _get_email_config
|
||
from email.mime.text import MIMEText
|
||
from email.mime.multipart import MIMEMultipart
|
||
from datetime import datetime as _dt
|
||
# `reminder_email_account_id` lets the user pick WHICH email
|
||
# account to send reminders from (when they have several
|
||
# configured in Integrations). Falls back to the default
|
||
# account when no explicit choice is saved.
|
||
_acc_id = (settings.get("reminder_email_account_id") or "").strip() or None
|
||
cfg = _get_email_config(account_id=_acc_id, owner=owner or "")
|
||
if not (cfg.get("smtp_host") and cfg.get("smtp_user") and cfg.get("smtp_password")):
|
||
try:
|
||
from core.database import SessionLocal as _SL, EmailAccount as _EA
|
||
from sqlalchemy import and_, or_
|
||
db = _SL()
|
||
try:
|
||
q = db.query(_EA).filter(_EA.enabled == True) # noqa: E712
|
||
if owner:
|
||
unowned = or_(_EA.owner == None, _EA.owner == "") # noqa: E711
|
||
same_mailbox = or_(_EA.imap_user == owner, _EA.from_address == owner)
|
||
q = q.filter(or_(_EA.owner == owner, and_(unowned, same_mailbox)))
|
||
for row in q.order_by(_EA.is_default.desc(), _EA.created_at.asc()).all():
|
||
trial = _get_email_config(account_id=row.id, owner=owner or "")
|
||
if trial.get("smtp_host") and trial.get("smtp_user") and trial.get("smtp_password"):
|
||
cfg = trial
|
||
break
|
||
finally:
|
||
db.close()
|
||
except Exception as _fallback_error:
|
||
logger.debug(f"Reminder SMTP fallback lookup failed: {_fallback_error}")
|
||
from_addr = (cfg.get("from_address") or cfg.get("smtp_user") or "").strip()
|
||
recipient = (settings.get("reminder_email_to") or "").strip() or from_addr
|
||
# Loud diagnostic so we can see WHY a reminder didn't send (the
|
||
# previous "silently no-op when cfg has no smtp_host" was invisible).
|
||
logger.info(
|
||
f"dispatch_reminder[email] note_id={note_id} owner={owner!r} "
|
||
f"smtp_host={cfg.get('smtp_host')!r} smtp_user={cfg.get('smtp_user')!r} "
|
||
f"from={from_addr!r} recipient={recipient!r} "
|
||
f"account_name={cfg.get('account_name')!r}"
|
||
)
|
||
missing = []
|
||
if not cfg.get("smtp_host"):
|
||
missing.append("SMTP host")
|
||
if not cfg.get("smtp_user"):
|
||
missing.append("SMTP user")
|
||
if not cfg.get("smtp_password"):
|
||
missing.append("SMTP password")
|
||
if not from_addr:
|
||
missing.append("from address")
|
||
if not recipient:
|
||
missing.append("recipient")
|
||
if missing:
|
||
email_error = "Missing " + ", ".join(missing)
|
||
logger.warning(
|
||
"Reminder email not sent for note_id=%s account=%r: %s",
|
||
note_id, cfg.get("account_name"), email_error,
|
||
)
|
||
else:
|
||
msg = MIMEMultipart("alternative")
|
||
msg["From"] = from_addr
|
||
msg["To"] = recipient
|
||
_t = title or 'Note'
|
||
_t = _t[len('Reminder:'):].strip() if _t.lower().startswith('reminder:') else _t
|
||
msg["Subject"] = f"Reminder (Odysseus): {_t}"
|
||
msg["Date"] = _dt.utcnow().strftime("%a, %d %b %Y %H:%M:%S +0000")
|
||
msg["X-Odysseus-Origin"] = "odysseus-ui"
|
||
msg["X-Odysseus-Kind"] = "reminder"
|
||
msg["X-Odysseus-Ref"] = str(note_id)
|
||
# Body shape: synthesis (warm sentence) → blank line → bold
|
||
# title header → note details. The title was previously only
|
||
# in the subject line, so the email read like a faceless
|
||
# to-do list with no anchor to which note triggered it.
|
||
_body_chunks = []
|
||
if synthesis:
|
||
_body_chunks.append(synthesis)
|
||
if _t:
|
||
_body_chunks.append(_t)
|
||
if note_body:
|
||
_body_chunks.append(note_body)
|
||
plain = "\n\n".join(_body_chunks) if _body_chunks else title
|
||
msg.attach(MIMEText(plain, "plain", "utf-8"))
|
||
|
||
def _smtp_send():
|
||
from routes.email_helpers import _send_smtp_message
|
||
_send_smtp_message(cfg, from_addr, [recipient], msg.as_string())
|
||
|
||
import asyncio as _aio
|
||
await _aio.to_thread(_smtp_send)
|
||
email_sent = True
|
||
except Exception as e:
|
||
email_error = str(e) or e.__class__.__name__
|
||
logger.warning(f"Reminder email send failed: {e}")
|
||
|
||
ntfy_sent = False
|
||
ntfy_error = ""
|
||
if channel == "ntfy":
|
||
try:
|
||
from src.integrations import load_integrations
|
||
import httpx
|
||
intg = next(
|
||
(i for i in load_integrations()
|
||
if i.get("preset") == "ntfy" and i.get("enabled", True) and i.get("base_url")),
|
||
None,
|
||
)
|
||
if intg:
|
||
base = intg["base_url"].rstrip("/")
|
||
topic = settings.get("reminder_ntfy_topic") or "reminders"
|
||
ntfy_body = synthesis or note_body or title
|
||
hdrs = {"Title": title or "Reminder", "Priority": "high", "Tags": "bell"}
|
||
api_key = intg.get("api_key", "")
|
||
if api_key:
|
||
hdrs["Authorization"] = f"Bearer {api_key}"
|
||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||
resp = await client.post(f"{base}/{topic}", content=ntfy_body, headers=hdrs)
|
||
ntfy_sent = resp.is_success
|
||
if not ntfy_sent:
|
||
ntfy_error = f"ntfy returned HTTP {resp.status_code}"
|
||
else:
|
||
ntfy_error = "No enabled ntfy integration"
|
||
except Exception as e:
|
||
ntfy_error = str(e) or e.__class__.__name__
|
||
logger.warning(f"Reminder ntfy send failed: {e}")
|
||
|
||
# In-app browser notification ALWAYS fires (regardless of channel). The
|
||
# frontend polls `/api/tasks/notifications` and turns any entry with a
|
||
# `body` into a real `Notification(...)` — same surface as task-success
|
||
# popups. Lets the user see reminders inside the app even when the
|
||
# primary channel is email/ntfy and the tab is open.
|
||
browser_sent = False
|
||
local_browser_sent = (not queue_browser and channel == "browser")
|
||
if queue_browser and _scheduler_ref is not None:
|
||
try:
|
||
_scheduler_ref.add_notification(
|
||
task_name=title or "Reminder",
|
||
status="success",
|
||
task_id=f"reminder-{note_id}",
|
||
owner=owner or None,
|
||
body=(synthesis or note_body or title or "").strip()[:500] or "Reminder",
|
||
)
|
||
browser_sent = True
|
||
except Exception as _e:
|
||
logger.debug(f"dispatch_reminder: in-app notif push failed: {_e}")
|
||
|
||
# Dedupe across paths: write to the same cache file `action_ping_notes`
|
||
# reads, so the background scanner's REPING_MIN window suppresses a
|
||
# second send for the same note within 25 min. Without this, a note
|
||
# whose due_date fires while the user has the app open got TWO emails
|
||
# (frontend-fired here + background-fired by ping_notes 0–5 min later).
|
||
if (email_sent or ntfy_sent or browser_sent or local_browser_sent) and note_id:
|
||
try:
|
||
import json as _json
|
||
from datetime import datetime as _dt, timezone as _tz
|
||
from pathlib import Path as _P
|
||
# Per-owner cache so the scanner's prune step on user A's run
|
||
# doesn't drop user B's just-fired entry (review C4).
|
||
_STATE = cache_path
|
||
if _STATE is None:
|
||
_slug = "".join(c if (c.isalnum() or c in "-_.@") else "_" for c in (owner or "default"))
|
||
_STATE = _P(f"data/note_pings_{_slug}.json")
|
||
_STATE.parent.mkdir(parents=True, exist_ok=True)
|
||
try:
|
||
_cache = cache or (_json.loads(_STATE.read_text()) if _STATE.exists() else {})
|
||
except Exception:
|
||
_cache = {}
|
||
sent_channel = "email" if email_sent else "ntfy" if ntfy_sent else "browser"
|
||
_cache[cache_key or str(note_id)] = {
|
||
"at": _dt.now(_tz.utc).isoformat(),
|
||
"channel": sent_channel,
|
||
}
|
||
_STATE.write_text(_json.dumps(_cache))
|
||
except Exception as _e:
|
||
logger.debug(f"dispatch_reminder: cache write failed: {_e}")
|
||
|
||
return {
|
||
"synthesis": synthesis,
|
||
"email_sent": email_sent,
|
||
"email_error": email_error,
|
||
"ntfy_sent": ntfy_sent,
|
||
"ntfy_error": ntfy_error,
|
||
"browser_sent": browser_sent or local_browser_sent,
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Router factory
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def setup_note_routes(task_scheduler=None):
|
||
# Expose the scheduler to module-level `dispatch_reminder` so reminders
|
||
# can also push to the in-app notification queue (the polling system
|
||
# turns each entry into a real browser Notification + the existing
|
||
# tasks-tab badge / dot system).
|
||
global _scheduler_ref
|
||
_scheduler_ref = task_scheduler
|
||
|
||
router = APIRouter(prefix="/api/notes", tags=["notes"])
|
||
|
||
def _owner(request: Request) -> Optional[str]:
|
||
return get_current_user(request)
|
||
|
||
# --- LIST ---
|
||
@router.get("")
|
||
def list_notes(
|
||
request: Request,
|
||
archived: Optional[bool] = None,
|
||
label: Optional[str] = None,
|
||
):
|
||
user = _owner(request)
|
||
db = SessionLocal()
|
||
try:
|
||
q = db.query(Note)
|
||
if user is not None:
|
||
q = q.filter(Note.owner == user)
|
||
if archived is not None:
|
||
q = q.filter(Note.archived == archived)
|
||
else:
|
||
q = q.filter(Note.archived == False)
|
||
if label:
|
||
q = q.filter(Note.label == label)
|
||
# Archived view: most recently archived first. Active view: pin + manual order.
|
||
if archived is True:
|
||
notes = q.order_by(Note.updated_at.desc()).all()
|
||
else:
|
||
notes = q.order_by(Note.pinned.desc(), Note.sort_order.asc(), Note.updated_at.desc()).all()
|
||
return {"notes": [_note_to_dict(n) for n in notes]}
|
||
finally:
|
||
db.close()
|
||
|
||
# --- CREATE ---
|
||
@router.post("")
|
||
def create_note(request: Request, body: NoteCreate):
|
||
user = _owner(request)
|
||
db = SessionLocal()
|
||
try:
|
||
note = Note(
|
||
id=str(uuid.uuid4()),
|
||
owner=user,
|
||
title=body.title,
|
||
content=body.content,
|
||
items=json.dumps(body.items) if body.items is not None else None,
|
||
note_type=body.note_type,
|
||
color=body.color,
|
||
label=body.label,
|
||
pinned=body.pinned,
|
||
due_date=body.due_date,
|
||
source=body.source,
|
||
session_id=body.session_id,
|
||
image_url=body.image_url,
|
||
repeat=body.repeat or "none",
|
||
sort_order=body.sort_order if body.sort_order is not None else 0,
|
||
)
|
||
db.add(note)
|
||
db.commit()
|
||
db.refresh(note)
|
||
return _note_to_dict(note)
|
||
finally:
|
||
db.close()
|
||
|
||
# --- GET ONE ---
|
||
@router.get("/{note_id}")
|
||
def get_note(request: Request, note_id: str):
|
||
user = _owner(request)
|
||
db = SessionLocal()
|
||
try:
|
||
note = db.query(Note).filter(Note.id == note_id).first()
|
||
if not note:
|
||
raise HTTPException(404, "Note not found")
|
||
# SECURITY: strict ownership — previously `note.owner and note.owner != user`
|
||
# let any user touch a row whose owner field was null/empty.
|
||
if user is not None and note.owner != user:
|
||
raise HTTPException(404, "Note not found")
|
||
return _note_to_dict(note)
|
||
finally:
|
||
db.close()
|
||
|
||
# --- UPDATE ---
|
||
@router.put("/{note_id}")
|
||
def update_note(request: Request, note_id: str, body: NoteUpdate):
|
||
user = _owner(request)
|
||
db = SessionLocal()
|
||
try:
|
||
note = db.query(Note).filter(Note.id == note_id).first()
|
||
if not note:
|
||
raise HTTPException(404, "Note not found")
|
||
# SECURITY: strict ownership — previously `note.owner and note.owner != user`
|
||
# let any user touch a row whose owner field was null/empty.
|
||
if user is not None and note.owner != user:
|
||
raise HTTPException(404, "Note not found")
|
||
|
||
if body.title is not None:
|
||
note.title = body.title
|
||
if body.content is not None:
|
||
note.content = body.content
|
||
if body.items is not None:
|
||
note.items = json.dumps(body.items)
|
||
flag_modified(note, "items")
|
||
if body.note_type is not None:
|
||
note.note_type = body.note_type
|
||
if body.color is not None:
|
||
note.color = body.color
|
||
if body.label is not None:
|
||
note.label = body.label
|
||
if body.pinned is not None:
|
||
note.pinned = body.pinned
|
||
if body.archived is not None:
|
||
note.archived = body.archived
|
||
if body.due_date is not None:
|
||
note.due_date = body.due_date
|
||
if body.image_url is not None:
|
||
note.image_url = body.image_url
|
||
if body.repeat is not None:
|
||
note.repeat = body.repeat
|
||
if body.sort_order is not None:
|
||
note.sort_order = body.sort_order
|
||
if body.agent_session_id is not None:
|
||
note.agent_session_id = body.agent_session_id
|
||
|
||
db.commit()
|
||
db.refresh(note)
|
||
return _note_to_dict(note)
|
||
finally:
|
||
db.close()
|
||
|
||
# --- DELETE ---
|
||
@router.delete("/{note_id}")
|
||
def delete_note(request: Request, note_id: str):
|
||
user = _owner(request)
|
||
db = SessionLocal()
|
||
try:
|
||
note = db.query(Note).filter(Note.id == note_id).first()
|
||
if not note:
|
||
raise HTTPException(404, "Note not found")
|
||
# SECURITY: strict ownership — previously `note.owner and note.owner != user`
|
||
# let any user touch a row whose owner field was null/empty.
|
||
if user is not None and note.owner != user:
|
||
raise HTTPException(404, "Note not found")
|
||
db.delete(note)
|
||
db.commit()
|
||
return {"ok": True}
|
||
finally:
|
||
db.close()
|
||
|
||
# --- TOGGLE PIN ---
|
||
@router.post("/{note_id}/pin")
|
||
def toggle_pin(request: Request, note_id: str):
|
||
user = _owner(request)
|
||
db = SessionLocal()
|
||
try:
|
||
note = db.query(Note).filter(Note.id == note_id).first()
|
||
if not note:
|
||
raise HTTPException(404, "Note not found")
|
||
# SECURITY: strict ownership — previously `note.owner and note.owner != user`
|
||
# let any user touch a row whose owner field was null/empty.
|
||
if user is not None and note.owner != user:
|
||
raise HTTPException(404, "Note not found")
|
||
note.pinned = not note.pinned
|
||
db.commit()
|
||
return {"ok": True, "pinned": note.pinned}
|
||
finally:
|
||
db.close()
|
||
|
||
# --- TOGGLE ARCHIVE ---
|
||
@router.post("/{note_id}/archive")
|
||
def toggle_archive(request: Request, note_id: str):
|
||
user = _owner(request)
|
||
db = SessionLocal()
|
||
try:
|
||
note = db.query(Note).filter(Note.id == note_id).first()
|
||
if not note:
|
||
raise HTTPException(404, "Note not found")
|
||
# SECURITY: strict ownership — previously `note.owner and note.owner != user`
|
||
# let any user touch a row whose owner field was null/empty.
|
||
if user is not None and note.owner != user:
|
||
raise HTTPException(404, "Note not found")
|
||
note.archived = not note.archived
|
||
db.commit()
|
||
return {"ok": True, "archived": note.archived}
|
||
finally:
|
||
db.close()
|
||
|
||
# --- TOGGLE CHECKLIST ITEM ---
|
||
@router.post("/{note_id}/items/{index}/toggle")
|
||
def toggle_item(request: Request, note_id: str, index: int):
|
||
user = _owner(request)
|
||
db = SessionLocal()
|
||
try:
|
||
note = db.query(Note).filter(Note.id == note_id).first()
|
||
if not note:
|
||
raise HTTPException(404, "Note not found")
|
||
# SECURITY: strict ownership — previously `note.owner and note.owner != user`
|
||
# let any user touch a row whose owner field was null/empty.
|
||
if user is not None and note.owner != user:
|
||
raise HTTPException(404, "Note not found")
|
||
if not note.items:
|
||
raise HTTPException(400, "Note has no checklist items")
|
||
items = json.loads(note.items)
|
||
if index < 0 or index >= len(items):
|
||
raise HTTPException(400, f"Item index {index} out of range")
|
||
items[index]["done"] = not items[index].get("done", False)
|
||
note.items = json.dumps(items)
|
||
flag_modified(note, "items")
|
||
db.commit()
|
||
return {"ok": True, "items": items}
|
||
finally:
|
||
db.close()
|
||
|
||
# --- FIRE REMINDER ---
|
||
@router.post("/fire-reminder")
|
||
async def fire_reminder(request: Request):
|
||
"""Dispatch a reminder according to user settings.
|
||
|
||
Called by the frontend when a reminder fires. Optionally generates an
|
||
LLM synthesis line and/or sends an email through configured SMTP.
|
||
Returns {synthesis, email_sent}.
|
||
"""
|
||
# Gate against anonymous callers — LLM synthesis can burn tokens.
|
||
from src.auth_helpers import get_current_user as _gcu
|
||
if not _gcu(request):
|
||
raise HTTPException(401, "Not authenticated")
|
||
body = await request.json()
|
||
note_id = body.get("note_id")
|
||
title = (body.get("title") or "").strip()
|
||
note_body = (body.get("body") or "").strip()
|
||
if not note_id:
|
||
raise HTTPException(400, "note_id required")
|
||
|
||
# Delegate to the module-level helper so background tasks can reuse
|
||
# the same dispatch without an HTTP roundtrip + auth cookie.
|
||
return await dispatch_reminder(
|
||
title=title, note_body=note_body, note_id=note_id,
|
||
owner=_gcu(request) or "",
|
||
queue_browser=False,
|
||
)
|
||
|
||
# --- REORDER NOTES ---
|
||
@router.post("/reorder")
|
||
async def reorder_notes(request: Request):
|
||
"""Update sort_order for a list of note IDs in the order provided."""
|
||
user = _owner(request)
|
||
body = await request.json()
|
||
ids = body.get("ids", [])
|
||
if not isinstance(ids, list):
|
||
raise HTTPException(400, "ids must be a list")
|
||
# v2 review HIGH-12: drop the legacy `(owner == user) | (owner ==
|
||
# None)` OR which let an authenticated user silently reorder
|
||
# every legacy-null-owner note belonging to other accounts. In
|
||
# an unconfigured (single-user) auth deploy the OR is still safe
|
||
# because there's no second user to attack; we keep that branch
|
||
# explicit and gated on AuthManager.is_configured.
|
||
try:
|
||
from core.auth import AuthManager
|
||
_allow_null = not AuthManager().is_configured
|
||
except Exception:
|
||
_allow_null = False
|
||
db = SessionLocal()
|
||
try:
|
||
for i, nid in enumerate(ids):
|
||
q = db.query(Note).filter(Note.id == nid)
|
||
if user is not None:
|
||
if _allow_null:
|
||
q = q.filter((Note.owner == user) | (Note.owner == None)) # noqa: E711
|
||
else:
|
||
q = q.filter(Note.owner == user)
|
||
note = q.first()
|
||
if note:
|
||
note.sort_order = i
|
||
db.commit()
|
||
return {"ok": True, "count": len(ids)}
|
||
finally:
|
||
db.close()
|
||
|
||
return router
|