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:
@@ -333,7 +333,7 @@ async function save() {
|
|||||||
savedParentId = parentId.value;
|
savedParentId = parentId.value;
|
||||||
dirty.value = false;
|
dirty.value = false;
|
||||||
toast.show("Task saved");
|
toast.show("Task saved");
|
||||||
window.location.reload();
|
router.push(`/tasks/${taskId.value}`);
|
||||||
} else {
|
} else {
|
||||||
const task = await store.createTask(data);
|
const task = await store.createTask(data);
|
||||||
dirty.value = false;
|
dirty.value = false;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from quart import Blueprint, jsonify, request
|
|||||||
from fabledassistant.auth import login_required, get_current_user_id
|
from fabledassistant.auth import login_required, get_current_user_id
|
||||||
from fabledassistant.models.note import TaskPriority, TaskStatus
|
from fabledassistant.models.note import TaskPriority, TaskStatus
|
||||||
from fabledassistant.routes.utils import not_found, parse_iso_date, parse_pagination
|
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.embeddings import upsert_note_embedding
|
||||||
from fabledassistant.services.notes import (
|
from fabledassistant.services.notes import (
|
||||||
create_note,
|
create_note,
|
||||||
@@ -154,6 +155,13 @@ async def get_task_route(task_id: int):
|
|||||||
@login_required
|
@login_required
|
||||||
async def update_task_route(task_id: int):
|
async def update_task_route(task_id: int):
|
||||||
uid = get_current_user_id()
|
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()
|
data = await request.get_json()
|
||||||
fields = {}
|
fields = {}
|
||||||
for key in ("title",):
|
for key in ("title",):
|
||||||
@@ -198,12 +206,12 @@ async def update_task_route(task_id: int):
|
|||||||
if recurrence_rule is not _UNSET:
|
if recurrence_rule is not _UNSET:
|
||||||
fields["recurrence_rule"] = recurrence_rule
|
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:
|
if task is None:
|
||||||
return not_found("Task")
|
return not_found("Task")
|
||||||
text = f"{task.title}\n{task.body}".strip() if task.body else (task.title or "")
|
text = f"{task.title}\n{task.body}".strip() if task.body else (task.title or "")
|
||||||
if text:
|
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())
|
return jsonify(task.to_dict())
|
||||||
|
|
||||||
|
|
||||||
@@ -211,17 +219,23 @@ async def update_task_route(task_id: int):
|
|||||||
@login_required
|
@login_required
|
||||||
async def patch_task_status(task_id: int):
|
async def patch_task_status(task_id: int):
|
||||||
uid = get_current_user_id()
|
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()
|
data = await request.get_json()
|
||||||
status_val = data.get("status")
|
status_val = data.get("status")
|
||||||
if not status_val:
|
if not status_val:
|
||||||
return jsonify({"error": "status is required"}), 400
|
return jsonify({"error": "status is required"}), 400
|
||||||
|
|
||||||
try:
|
try:
|
||||||
TaskStatus(status_val)
|
TaskStatus(status_val)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return jsonify({"error": f"Invalid status: {status_val}"}), 400
|
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:
|
if task is None:
|
||||||
return not_found("Task")
|
return not_found("Task")
|
||||||
return jsonify(task.to_dict())
|
return jsonify(task.to_dict())
|
||||||
@@ -252,7 +266,13 @@ async def recurrence_preview_route(task_id: int):
|
|||||||
@login_required
|
@login_required
|
||||||
async def delete_task_route(task_id: int):
|
async def delete_task_route(task_id: int):
|
||||||
uid = get_current_user_id()
|
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:
|
if not deleted:
|
||||||
return not_found("Task")
|
return not_found("Task")
|
||||||
return "", 204
|
return "", 204
|
||||||
|
|||||||
@@ -80,6 +80,11 @@ _CORE_TOOLS = [
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Optional task description or details",
|
"description": "Optional task description or details",
|
||||||
},
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["todo", "in_progress", "done", "cancelled"],
|
||||||
|
"description": "Initial task status (default: todo)",
|
||||||
|
},
|
||||||
"due_date": {
|
"due_date": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Optional due date in YYYY-MM-DD format",
|
"description": "Optional due date in YYYY-MM-DD format",
|
||||||
@@ -89,6 +94,11 @@ _CORE_TOOLS = [
|
|||||||
"enum": ["none", "low", "medium", "high"],
|
"enum": ["none", "low", "medium", "high"],
|
||||||
"description": "Task priority level",
|
"description": "Task priority level",
|
||||||
},
|
},
|
||||||
|
"tags": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
"description": "Tags for the task (without # prefix)",
|
||||||
|
},
|
||||||
"project": {
|
"project": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Optional project name to assign this task to",
|
"description": "Optional project name to assign this task to",
|
||||||
@@ -204,6 +214,10 @@ _CORE_TOOLS = [
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Optional project name to assign this note/task to (set to empty string to remove project)",
|
"description": "Optional project name to assign this note/task to (set to empty string to remove project)",
|
||||||
},
|
},
|
||||||
|
"milestone": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional milestone title within the project to assign this task to (set to empty string to remove milestone)",
|
||||||
|
},
|
||||||
"recurrence_rule": {
|
"recurrence_rule": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Set or update recurrence (same format as create_task). Pass null to remove.",
|
"description": "Set or update recurrence (same format as create_task). Pass null to remove.",
|
||||||
@@ -344,6 +358,10 @@ _CORE_TOOLS = [
|
|||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"q": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional keyword filter — searches title and body",
|
||||||
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
@@ -1110,11 +1128,16 @@ async def execute_tool(
|
|||||||
best_score, best_note = sem_hits[0]
|
best_score, best_note = sem_hits[0]
|
||||||
return {"success": False, "requires_confirmation": True, "similar_note": {"id": best_note.id, "title": best_note.title}, "error": f"A task with very similar content exists: '{best_note.title}' (semantic similarity: {best_score:.0%}). Ask the user to confirm before creating a separate entry."}
|
return {"success": False, "requires_confirmation": True, "similar_note": {"id": best_note.id, "title": best_note.title}, "error": f"A task with very similar content exists: '{best_note.title}' (semantic similarity: {best_score:.0%}). Ask the user to confirm before creating a separate entry."}
|
||||||
|
|
||||||
|
task_tags = arguments.get("tags", [])
|
||||||
|
if not isinstance(task_tags, list):
|
||||||
|
task_tags = []
|
||||||
|
task_status = arguments.get("status", "todo")
|
||||||
note = await create_note(
|
note = await create_note(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
title=task_title,
|
title=task_title,
|
||||||
body=task_body,
|
body=task_body,
|
||||||
status="todo",
|
tags=task_tags,
|
||||||
|
status=task_status,
|
||||||
priority=arguments.get("priority", "none"),
|
priority=arguments.get("priority", "none"),
|
||||||
due_date=_parse_due_date(arguments.get("due_date")),
|
due_date=_parse_due_date(arguments.get("due_date")),
|
||||||
project_id=pre_fields.get("project_id"),
|
project_id=pre_fields.get("project_id"),
|
||||||
@@ -1139,6 +1162,8 @@ async def execute_tool(
|
|||||||
"priority": note.priority,
|
"priority": note.priority,
|
||||||
"due_date": str(note.due_date) if note.due_date else None,
|
"due_date": str(note.due_date) if note.due_date else None,
|
||||||
"project_id": note.project_id,
|
"project_id": note.project_id,
|
||||||
|
"milestone_id": note.milestone_id,
|
||||||
|
"parent_id": note.parent_id,
|
||||||
},
|
},
|
||||||
"suggested_tags": suggested,
|
"suggested_tags": suggested,
|
||||||
}
|
}
|
||||||
@@ -1258,6 +1283,22 @@ async def execute_tool(
|
|||||||
update_fields["project_id"] = proj.id
|
update_fields["project_id"] = proj.id
|
||||||
else:
|
else:
|
||||||
update_fields["project_id"] = None
|
update_fields["project_id"] = None
|
||||||
|
update_fields["milestone_id"] = None # clear milestone when project is cleared
|
||||||
|
|
||||||
|
if "milestone" in arguments:
|
||||||
|
milestone_name = arguments["milestone"]
|
||||||
|
if milestone_name:
|
||||||
|
# Resolve the project context for milestone lookup
|
||||||
|
ref_project_id = update_fields.get("project_id") or note.project_id
|
||||||
|
if ref_project_id is None:
|
||||||
|
return {"success": False, "error": "Cannot assign a milestone without a project. Set project first."}
|
||||||
|
from fabledassistant.services.milestones import get_milestone_by_title as _gmbt_upd
|
||||||
|
ms = await _gmbt_upd(user_id, ref_project_id, milestone_name)
|
||||||
|
if ms is None:
|
||||||
|
return {"success": False, "error": f"Milestone '{milestone_name}' not found. Use list_milestones to see available milestones."}
|
||||||
|
update_fields["milestone_id"] = ms.id
|
||||||
|
else:
|
||||||
|
update_fields["milestone_id"] = None
|
||||||
|
|
||||||
updated = await update_note(user_id, note.id, **update_fields)
|
updated = await update_note(user_id, note.id, **update_fields)
|
||||||
if updated is None:
|
if updated is None:
|
||||||
@@ -1303,6 +1344,7 @@ async def execute_tool(
|
|||||||
milestone_id = ms.id
|
milestone_id = ms.id
|
||||||
notes, total = await list_notes(
|
notes, total = await list_notes(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
|
q=arguments.get("q") or None,
|
||||||
is_task=True,
|
is_task=True,
|
||||||
status=arguments.get("status"),
|
status=arguments.get("status"),
|
||||||
priority=arguments.get("priority"),
|
priority=arguments.get("priority"),
|
||||||
|
|||||||
Reference in New Issue
Block a user