fix(tasks): audit pass — permission checks, tool gaps, hard reload

- PUT/PATCH/DELETE /api/tasks/:id now use get_note_for_user + can_write_note
  so shared project editors can mutate tasks; owners unaffected
- PATCH /api/tasks/:id/status gets same treatment
- All write routes call update_note/delete_note with note.user_id (owner)
  not the accessing user's uid, matching the milestone fix pattern
- create_task tool gains tags (array) and status (enum) parameters;
  handler now passes tags to create_note and respects initial status
- create_task tool response now includes milestone_id and parent_id
- update_note tool gains milestone parameter; handler resolves the
  milestone by title within the note's current (or newly set) project,
  clears milestone_id when project is cleared
- list_tasks tool gains q keyword search parameter; passed through
  to list_notes
- TaskEditorView: replace window.location.reload() with
  router.push('/tasks/:id') after save

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-04 13:48:07 -04:00
parent c5191837fb
commit d9bd16633f
3 changed files with 69 additions and 7 deletions
+25 -5
View File
@@ -6,6 +6,7 @@ from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.models.note import TaskPriority, TaskStatus
from fabledassistant.routes.utils import not_found, parse_iso_date, parse_pagination
from fabledassistant.services.access import can_write_note
from fabledassistant.services.embeddings import upsert_note_embedding
from fabledassistant.services.notes import (
create_note,
@@ -154,6 +155,13 @@ async def get_task_route(task_id: int):
@login_required
async def update_task_route(task_id: int):
uid = get_current_user_id()
result = await get_note_for_user(uid, task_id)
if result is None:
return not_found("Task")
task_note, _ = result
if not await can_write_note(uid, task_id):
return jsonify({"error": "Permission denied"}), 403
data = await request.get_json()
fields = {}
for key in ("title",):
@@ -198,12 +206,12 @@ async def update_task_route(task_id: int):
if recurrence_rule is not _UNSET:
fields["recurrence_rule"] = recurrence_rule
task = await update_note(uid, task_id, **fields)
task = await update_note(task_note.user_id, task_id, **fields)
if task is None:
return not_found("Task")
text = f"{task.title}\n{task.body}".strip() if task.body else (task.title or "")
if text:
asyncio.create_task(upsert_note_embedding(task.id, uid, text))
asyncio.create_task(upsert_note_embedding(task.id, task_note.user_id, text))
return jsonify(task.to_dict())
@@ -211,17 +219,23 @@ async def update_task_route(task_id: int):
@login_required
async def patch_task_status(task_id: int):
uid = get_current_user_id()
result = await get_note_for_user(uid, task_id)
if result is None:
return not_found("Task")
task_note, _ = result
if not await can_write_note(uid, task_id):
return jsonify({"error": "Permission denied"}), 403
data = await request.get_json()
status_val = data.get("status")
if not status_val:
return jsonify({"error": "status is required"}), 400
try:
TaskStatus(status_val)
except ValueError:
return jsonify({"error": f"Invalid status: {status_val}"}), 400
task = await update_note(uid, task_id, status=status_val)
task = await update_note(task_note.user_id, task_id, status=status_val)
if task is None:
return not_found("Task")
return jsonify(task.to_dict())
@@ -252,7 +266,13 @@ async def recurrence_preview_route(task_id: int):
@login_required
async def delete_task_route(task_id: int):
uid = get_current_user_id()
deleted = await delete_note(uid, task_id)
result = await get_note_for_user(uid, task_id)
if result is None:
return not_found("Task")
task_note, _ = result
if not await can_write_note(uid, task_id):
return jsonify({"error": "Permission denied"}), 403
deleted = await delete_note(task_note.user_id, task_id)
if not deleted:
return not_found("Task")
return "", 204