"""Task board router — Taskz CRUD over Redis.""" from __future__ import annotations import json import re from datetime import datetime, timezone from typing import Any, Dict, List, Optional from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from app.redis_client import ( get_redis, get_board, list_boards, save_board, save_task, BOARD_KEY_PREFIX, BOARD_INDEX_KEY, ) router = APIRouter(prefix="/api/v1", tags=["tasks"]) def _now() -> str: return datetime.now(timezone.utc).isoformat() def _slugify(text: str) -> str: return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")[:20] def _make_board_code(project: str, title: str) -> str: proj = re.sub(r"[^a-z0-9]+", "", project.lower())[:12].upper() title_word = re.sub(r"[^a-z0-9]+", "", title.lower().split()[0])[:6].upper() return f"{proj}-{title_word}" def _next_task_number(board: Dict[str, Any]) -> int: tasks = board.get("tasks", []) return max((t.get("number", 0) for t in tasks), default=0) + 1 # ── Schemas ─────────────────────────────────────────────────────────────────── class BoardCreate(BaseModel): project: str title: str description: str = "" tags: List[str] = [] class TaskCreate(BaseModel): title: str description: str = "" priority: str = "medium" tags: List[str] = [] scope: Optional[str] = None class TaskUpdate(BaseModel): status: Optional[str] = None title: Optional[str] = None description: Optional[str] = None findings: Optional[str] = None priority: Optional[str] = None tags: Optional[List[str]] = None class BoardStatusUpdate(BaseModel): new_status: str = "completed" # ── Endpoints ───────────────────────────────────────────────────────────────── @router.get("/boards") def api_list_boards(project: Optional[str] = None, status: Optional[str] = None): r = get_redis() boards = list_boards(r, project=project, status=status) summaries = [] for b in boards: tasks = b.get("tasks", []) done = sum(1 for t in tasks if t["status"] == "done") summaries.append({ "board_id": b["board_id"], "board_code": b.get("board_code", ""), "project": b["project"], "title": b["title"], "description": b.get("description", ""), "status": b.get("status", "active"), "tags": b.get("tags", []), "task_count": len(tasks), "done_count": done, "created_at": b.get("created_at", ""), "updated_at": b.get("updated_at", ""), }) return {"boards": summaries, "total": len(summaries)} @router.post("/boards", status_code=201) def api_create_board(body: BoardCreate): r = get_redis() code = _make_board_code(body.project, body.title) board_id = f"{_slugify(body.project)}-{_slugify(body.title)}-{_now()[:10].replace('-', '')}" board = { "board_id": board_id, "board_code": code, "project": body.project, "title": body.title, "description": body.description, "status": "active", "tags": body.tags, "tasks": [], "created_at": _now(), "updated_at": _now(), } save_board(r, board) return {"board_id": board_id, "board_code": code} @router.get("/boards/{board_id}") def api_get_board(board_id: str): r = get_redis() board = get_board(r, board_id) if not board: raise HTTPException(status_code=404, detail="Board not found") return board @router.patch("/boards/{board_id}/status") def api_close_board(board_id: str, body: BoardStatusUpdate): r = get_redis() board = get_board(r, board_id) if not board: raise HTTPException(status_code=404, detail="Board not found") board["status"] = body.new_status board["updated_at"] = _now() save_board(r, board) return {"board_id": board_id, "status": body.new_status} @router.post("/boards/{board_id}/tasks", status_code=201) def api_add_task(board_id: str, body: TaskCreate): r = get_redis() board = get_board(r, board_id) if not board: raise HTTPException(status_code=404, detail="Board not found") code = board.get("board_code", board_id.upper()[:10]) num = _next_task_number(board) task_id = f"{code}-{num:03d}" task: Dict[str, Any] = { "task_id": task_id, "board_id": board_id, "number": num, "title": body.title, "description": body.description, "priority": body.priority, "status": "pending", "tags": body.tags, "scope": body.scope, "findings": [], "created_at": _now(), "updated_at": _now(), } save_task(r, task) return {"task_id": task_id} @router.patch("/tasks/{task_id}") def api_update_task(task_id: str, body: TaskUpdate): r = get_redis() # find the task inside any board from app.redis_client import list_boards as _lb, TASK_KEY_PREFIX raw = r.get(f"{TASK_KEY_PREFIX}{task_id}") if not raw: # fallback: scan boards for board in _lb(r): task = next((t for t in board.get("tasks", []) if t["task_id"] == task_id), None) if task: raw = json.dumps(task) break if not raw: raise HTTPException(status_code=404, detail="Task not found") task = json.loads(raw) valid_statuses = {"pending", "in_progress", "done", "blocked", "wont_do"} valid_priorities = {"low", "medium", "high", "critical"} if body.status: if body.status not in valid_statuses: raise HTTPException(status_code=400, detail=f"Invalid status: {body.status}") task["status"] = body.status if body.status == "done": task["completed_at"] = _now() if body.title: task["title"] = body.title if body.description is not None: task["description"] = body.description if body.findings: findings = task.setdefault("findings", []) findings.append({"text": body.findings, "at": _now()}) if body.priority: if body.priority not in valid_priorities: raise HTTPException(status_code=400, detail=f"Invalid priority: {body.priority}") task["priority"] = body.priority if body.tags is not None: task["tags"] = body.tags task["updated_at"] = _now() save_task(r, task) return {"task_id": task_id, "status": task["status"]} @router.delete("/tasks/{task_id}") def api_delete_task(task_id: str): r = get_redis() from app.redis_client import TASK_KEY_PREFIX raw = r.get(f"{TASK_KEY_PREFIX}{task_id}") if not raw: raise HTTPException(status_code=404, detail="Task not found") task = json.loads(raw) board = get_board(r, task["board_id"]) if board: board["tasks"] = [t for t in board.get("tasks", []) if t["task_id"] != task_id] board["updated_at"] = _now() save_board(r, board) r.delete(f"{TASK_KEY_PREFIX}{task_id}") return {"deleted": task_id}