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