This commit is contained in:
2026-03-29 06:57:34 -04:00
commit 37503231b3
31 changed files with 3444 additions and 0 deletions

298
app/routers/tasks.py Normal file
View File

@@ -0,0 +1,298 @@
from datetime import datetime as dt
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query
from app.middleware.auth import get_current_user_id
from app.models import (
BrainDumpRequest,
BrainDumpResponse,
PlanRequest,
PlanResponse,
StepOut,
TaskCreate,
TaskOut,
TaskUpdate,
)
from app.services import llm, push
from app.services.db import get_pool
router = APIRouter(prefix="/tasks", tags=["tasks"])
def _row_to_task(row) -> TaskOut:
return TaskOut(
id=row["id"],
user_id=row["user_id"],
title=row["title"],
description=row["description"],
priority=row["priority"],
status=row["status"],
deadline=row["deadline"],
estimated_minutes=row["estimated_minutes"],
source=row["source"],
tags=row["tags"] or [],
plan_type=row["plan_type"],
brain_dump_raw=row["brain_dump_raw"],
created_at=row["created_at"],
updated_at=row["updated_at"],
)
TASK_COLUMNS = "id, user_id, title, description, priority, status, deadline, estimated_minutes, source, tags, plan_type, brain_dump_raw, created_at, updated_at"
@router.get("", response_model=list[TaskOut])
async def list_tasks(
status: str | None = None,
priority: int | None = None,
sort_by: str = Query("priority", pattern="^(priority|deadline|created_at)$"),
user_id: str = Depends(get_current_user_id),
):
pool = await get_pool()
query = f"SELECT {TASK_COLUMNS} FROM tasks WHERE user_id = $1::uuid AND status != 'deferred'"
params: list = [user_id]
idx = 2
if status:
query += f" AND status = ${idx}"
params.append(status)
idx += 1
if priority is not None:
query += f" AND priority = ${idx}"
params.append(priority)
idx += 1
sort_dir = "DESC" if sort_by == "priority" else "ASC"
query += f" ORDER BY {sort_by} {sort_dir}"
rows = await pool.fetch(query, *params)
return [_row_to_task(r) for r in rows]
@router.get("/upcoming", response_model=list[TaskOut])
async def upcoming_tasks(user_id: str = Depends(get_current_user_id)):
pool = await get_pool()
rows = await pool.fetch(
f"""SELECT {TASK_COLUMNS} FROM tasks
WHERE user_id = $1::uuid AND deadline IS NOT NULL
AND deadline <= now() + interval '48 hours'
AND status NOT IN ('done', 'deferred')
ORDER BY deadline ASC""",
user_id,
)
return [_row_to_task(r) for r in rows]
@router.post("", response_model=TaskOut, status_code=201)
async def create_task(req: TaskCreate, user_id: str = Depends(get_current_user_id)):
pool = await get_pool()
row = await pool.fetchrow(
f"""INSERT INTO tasks (user_id, title, description, priority, deadline, estimated_minutes, tags)
VALUES ($1::uuid, $2, $3, $4, $5, $6, $7)
RETURNING {TASK_COLUMNS}""",
user_id,
req.title,
req.description,
req.priority,
req.deadline,
req.estimated_minutes,
req.tags,
)
await push.send_task_added(user_id, row["title"], step_count=0)
return _row_to_task(row)
@router.post("/brain-dump", response_model=BrainDumpResponse)
async def brain_dump(req: BrainDumpRequest, user_id: str = Depends(get_current_user_id)):
result = await llm.parse_brain_dump(req.raw_text, req.timezone)
pool = await get_pool()
parsed_tasks = []
for t in result.get("parsed_tasks", []):
# Parse deadline string from LLM into datetime (asyncpg needs datetime, not str)
deadline = t.get("deadline")
if isinstance(deadline, str) and deadline and deadline != "null":
try:
deadline = dt.fromisoformat(deadline)
except ValueError:
deadline = None
else:
deadline = None
est_minutes = t.get("estimated_minutes")
if isinstance(est_minutes, str):
try:
est_minutes = int(est_minutes)
except ValueError:
est_minutes = None
subtasks_raw = t.get("subtasks") or []
has_subtasks = len(subtasks_raw) > 0
row = await pool.fetchrow(
f"""INSERT INTO tasks (user_id, title, description, priority, deadline,
estimated_minutes, source, tags, brain_dump_raw, plan_type)
VALUES ($1::uuid, $2, $3, $4, $5::timestamptz, $6, $7, $8, $9, $10)
RETURNING {TASK_COLUMNS}""",
user_id,
t["title"],
t.get("description"),
int(t.get("priority", 0)),
deadline,
est_minutes,
req.source,
t.get("tags", []),
req.raw_text,
"brain_dump" if has_subtasks else None,
)
task_id = row["id"]
all_subtasks = []
sort_order = 1
for sub in subtasks_raw:
sub_est = sub.get("estimated_minutes")
if isinstance(sub_est, str):
try:
sub_est = int(sub_est)
except ValueError:
sub_est = None
sub_deadline = sub.get("deadline")
if isinstance(sub_deadline, str) and sub_deadline and sub_deadline != "null":
try:
sub_deadline = dt.fromisoformat(sub_deadline)
except ValueError:
sub_deadline = None
else:
sub_deadline = None
is_suggested = bool(sub.get("suggested", False))
# Only save non-suggested steps now; suggested ones are opt-in from the client
if not is_suggested:
await pool.fetchrow(
"""INSERT INTO steps (task_id, sort_order, title, description, estimated_minutes)
VALUES ($1, $2, $3, $4, $5)
RETURNING id""",
task_id,
sort_order,
sub["title"],
sub.get("description"),
sub_est,
)
sort_order += 1
all_subtasks.append({
"title": sub["title"],
"description": sub.get("description"),
"deadline": sub_deadline.isoformat() if sub_deadline else None,
"estimated_minutes": sub_est,
"suggested": is_suggested,
})
saved_count = sum(1 for s in all_subtasks if not s["suggested"])
await push.send_task_added(user_id, row["title"], step_count=saved_count)
parsed_tasks.append({
"task_id": str(row["id"]),
"title": row["title"],
"description": row["description"],
"priority": row["priority"],
"deadline": row["deadline"],
"estimated_minutes": row["estimated_minutes"],
"tags": row["tags"] or [],
"subtasks": all_subtasks,
})
return BrainDumpResponse(
parsed_tasks=parsed_tasks,
unparseable_fragments=result.get("unparseable_fragments", []),
ask_for_plans=True,
)
@router.post("/{task_id}/plan", response_model=PlanResponse)
async def plan_task(task_id: UUID, req: PlanRequest, user_id: str = Depends(get_current_user_id)):
pool = await get_pool()
task = await pool.fetchrow(
"SELECT id, title, description, estimated_minutes FROM tasks WHERE id = $1 AND user_id = $2::uuid",
task_id,
user_id,
)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
steps_data = await llm.generate_step_plan(task["title"], task["description"], task["estimated_minutes"])
steps = []
for s in steps_data:
row = await pool.fetchrow(
"""INSERT INTO steps (task_id, sort_order, title, description, estimated_minutes)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, task_id, sort_order, title, description, estimated_minutes,
status, checkpoint_note, last_checked_at, completed_at, created_at""",
task_id,
s["sort_order"],
s["title"],
s.get("description"),
s.get("estimated_minutes"),
)
steps.append(StepOut(**dict(row)))
await pool.execute(
"UPDATE tasks SET plan_type = $1, status = 'ready', updated_at = now() WHERE id = $2",
req.plan_type,
task_id,
)
await push.send_task_added(user_id, task["title"], step_count=len(steps))
return PlanResponse(task_id=task_id, plan_type=req.plan_type, steps=steps)
@router.patch("/{task_id}", response_model=TaskOut)
async def update_task(task_id: UUID, req: TaskUpdate, user_id: str = Depends(get_current_user_id)):
pool = await get_pool()
fields = []
values = []
idx = 3 # $1 = task_id, $2 = user_id
update_data = req.model_dump(exclude_unset=True)
for key, val in update_data.items():
fields.append(f"{key} = ${idx}")
values.append(val)
idx += 1
if not fields:
raise HTTPException(status_code=400, detail="No fields to update")
fields.append("updated_at = now()")
set_clause = ", ".join(fields)
row = await pool.fetchrow(
f"""UPDATE tasks SET {set_clause}
WHERE id = $1 AND user_id = $2::uuid
RETURNING {TASK_COLUMNS}""",
task_id,
user_id,
*values,
)
if not row:
raise HTTPException(status_code=404, detail="Task not found")
return _row_to_task(row)
@router.delete("/{task_id}", status_code=204)
async def delete_task(task_id: UUID, user_id: str = Depends(get_current_user_id)):
pool = await get_pool()
result = await pool.execute(
"UPDATE tasks SET status = 'deferred', updated_at = now() WHERE id = $1 AND user_id = $2::uuid",
task_id,
user_id,
)
if result == "UPDATE 0":
raise HTTPException(status_code=404, detail="Task not found")