damien-kaiser/taskloft

Taskloft — team task boards (owner / member / viewer), card history, my plate, overdue, blockers, AI checklists and summaries, usable from Claude and ChatGPT over MCP.

A remix template · 0 remixes · Remix damien-kaiser/taskloft

app/boards/doors/web.py

"""The web door: JSON routes plus the board page. Every route calls a use case and nothing else."""
from typing import List, Optional

from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from pydantic import BaseModel

from boards.application.use_cases import UNSET, Boards, NotFound
from boards.doors.identity import person_from
from boards.doors.page import PAGE
from boards.domain.model import Forbidden, Invalid, Person


class BoardIn(BaseModel):
    title: str
    lists: Optional[List[str]] = None


class Title(BaseModel):
    title: str


class ListIn(BaseModel):
    name: str
    done: bool = False


class Member(BaseModel):
    email: str
    role: str = "member"


class CardIn(BaseModel):
    title: str
    list: Optional[str] = None
    list_name: Optional[str] = None      # the starter's field name, still accepted
    description: str = ""
    assignee: Optional[str] = None
    due_date: Optional[str] = None
    labels: Optional[List[str]] = None    # List, not list: the field `list` above shadows the builtin


class CardPatch(BaseModel):
    title: Optional[str] = None
    description: Optional[str] = None
    due_date: Optional[str] = None
    labels: Optional[List[str]] = None


class Move(BaseModel):
    list: Optional[str] = None
    list_name: Optional[str] = None
    before: Optional[str] = None


class Assign(BaseModel):
    assignee: Optional[str] = None


class Blocker(BaseModel):
    blocked_by: str
    blocked: bool = True


class Items(BaseModel):
    items: List[str]


class Tick(BaseModel):
    done: bool = True


def build_router(boards: Boards) -> APIRouter:
    r = APIRouter()

    def me(request: Request) -> Person:
        p = person_from(request.headers)
        if p is None:
            raise HTTPException(401, "sign in first: /.dodil/auth/login")
        return p

    def run(fn, *args, **kwargs):
        try:
            return fn(*args, **kwargs)
        except NotFound as e:
            raise HTTPException(404, str(e))
        except Forbidden as e:
            raise HTTPException(403, str(e))
        except Invalid as e:
            raise HTTPException(422, str(e))

    # --- who am I, and what needs me ---------------------------------------------------------
    @r.get("/api/me")
    def whoami(request: Request):
        return {"email": me(request).email}

    @r.get("/api/me/plate")
    def plate(request: Request, days: int = 7):
        return run(boards.my_plate, me(request), days)

    @r.get("/api/me/overdue")
    def overdue(request: Request, board: Optional[str] = None):
        return run(boards.overdue, me(request), board)

    @r.get("/api/search")
    def search(request: Request, q: str, board: Optional[str] = None, meaning: bool = False):
        if meaning:   # by meaning (Dodil Models embeddings), not by words
            return run(boards.search_by_meaning, me(request), q, board)
        return run(boards.search_cards, me(request), q, board)

    # --- boards, people, lists ---------------------------------------------------------------
    @r.get("/api/boards")
    def my_boards(request: Request):
        return run(boards.my_boards, me(request))

    @r.post("/api/boards", status_code=201)
    def create_board(body: BoardIn, request: Request):
        return run(boards.create_board, me(request), body.title, body.lists)

    @r.get("/api/boards/{board_id}")
    def show_board(board_id: str, request: Request):
        return run(boards.show_board, me(request), board_id)

    @r.patch("/api/boards/{board_id}")
    def rename_board(board_id: str, body: Title, request: Request):
        return run(boards.rename_board, me(request), board_id, body.title)

    @r.get("/api/boards/{board_id}/activity")
    def activity(board_id: str, request: Request, limit: int = 30):
        return run(boards.board_activity, me(request), board_id, limit)

    @r.get("/api/boards/{board_id}/summary")
    def summary(board_id: str, request: Request):
        return run(boards.board_summary, me(request), board_id)

    @r.get("/api/boards/{board_id}/members")
    def members(board_id: str, request: Request):
        return run(boards.members, me(request), board_id)

    @r.post("/api/boards/{board_id}/members", status_code=201)
    def invite(board_id: str, body: Member, request: Request):
        return run(boards.invite_member, me(request), board_id, body.email, body.role)

    @r.delete("/api/boards/{board_id}/members/{email}")
    def remove(board_id: str, email: str, request: Request):
        return run(boards.remove_member, me(request), board_id, email)

    @r.post("/api/boards/{board_id}/lists", status_code=201)
    def add_list(board_id: str, body: ListIn, request: Request):
        return run(boards.add_list, me(request), board_id, body.name, body.done)

    @r.patch("/api/boards/{board_id}/lists/{list_id}")
    def rename_list(board_id: str, list_id: str, body: ListIn, request: Request):
        return run(boards.rename_list, me(request), board_id, list_id, body.name)

    # --- cards -------------------------------------------------------------------------------
    @r.post("/api/boards/{board_id}/cards", status_code=201)
    def add_card(board_id: str, body: CardIn, request: Request):
        return run(boards.add_card, me(request), board_id, body.title, body.list or body.list_name,
                   body.description, body.assignee, body.due_date, body.labels)

    @r.get("/api/cards/{card}")
    def card(card: str, request: Request):
        return run(boards.card, me(request), card)

    @r.patch("/api/cards/{card}")
    def update_card(card: str, body: CardPatch, request: Request):
        sent = body.model_fields_set   # only what was sent changes; due_date: null clears it
        return run(boards.update_card, me(request), card,
                   body.title if "title" in sent else UNSET,
                   body.description if "description" in sent else UNSET,
                   body.due_date if "due_date" in sent else UNSET,
                   body.labels if "labels" in sent else UNSET)

    @r.post("/api/cards/{card}/move")
    def move_card(card: str, body: Move, request: Request):
        return run(boards.move_card, me(request), card, body.list or body.list_name, body.before)

    @r.post("/api/cards/{card}/assign")
    def assign(card: str, body: Assign, request: Request):
        return run(boards.assign_card, me(request), card, body.assignee)

    @r.get("/api/cards/{card}/similar")
    def similar(card: str, request: Request, k: int = 5):
        return run(boards.similar_cards, me(request), card, k)

    @r.get("/api/cards/{card}/history")
    def history(card: str, request: Request):
        return run(boards.card_history, me(request), card)

    @r.post("/api/cards/{card}/blockers")
    def blockers(card: str, body: Blocker, request: Request):
        return run(boards.set_blocked_by, me(request), card, body.blocked_by, body.blocked)

    @r.get("/api/cards/{card}/blocking")
    def blocking(card: str, request: Request):
        return run(boards.what_is_blocking, me(request), card)

    @r.post("/api/cards/{card}/checklist")
    def add_items(card: str, body: Items, request: Request):
        return run(boards.add_checklist_items, me(request), card, body.items)

    @r.post("/api/cards/{card}/checklist/{item}")
    def tick(card: str, item: str, body: Tick, request: Request):
        return run(boards.tick_checklist_item, me(request), card, item, body.done)

    @r.post("/api/cards/{card}/checklist-ai")
    def make_checklist(card: str, request: Request):
        return run(boards.make_checklist, me(request), card)

    # kept from the starter: an AI suggestion that changes nothing
    @r.post("/api/cards/{card}/checklist-suggestion")
    def suggest(card: str, request: Request):
        return run(boards.suggest_checklist, me(request), card)

    @r.get("/", response_class=HTMLResponse)
    def page(request: Request):
        if person_from(request.headers) is None:
            return RedirectResponse("/.dodil/auth/login?return_to=/", status_code=303)
        return PAGE

    return r