sawera/sawera-boards

Sawera Boards - a team task board for the Dodil Boards Challenge. Boards, lists, cards, roles, activity log, MCP actions and AI extras. FastAPI + DataK3 + appid + Models + MCP.

A remix template · 0 remixes · Remix sawera/sawera-boards

app/boards/doors/web.py

"""The web door: JSON routes plus the board page. Every route calls a use case and nothing else,
so the rules (roles, visibility, cycles) hold here exactly as they do over MCP."""
from datetime import date
from typing import Optional

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

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


def _parse_date(value):
    if value in (None, ""):
        return None
    try:
        return date.fromisoformat(str(value))
    except ValueError:
        raise Invalid("due date must look like 2026-11-03")


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))
        except Exception as e:  # surface the real cause instead of a bare 500
            import sys, traceback
            traceback.print_exc(file=sys.stdout)
            print(f"APIERR {type(e).__name__}: {e}", file=sys.stdout, flush=True)
            raise HTTPException(500, f"{type(e).__name__}: {e}")

    class Title(BaseModel):
        title: str

    class Name(BaseModel):
        name: str

    class CardIn(BaseModel):
        title: str
        list_name: Optional[str] = None
        list_id: Optional[str] = None
        description: str = ""
        assignee: Optional[str] = None
        due_date: Optional[str] = None
        labels: list = []

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

    class Move(BaseModel):
        list_name: Optional[str] = None
        list_id: Optional[str] = None
        index: Optional[int] = None

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

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

    class Blockers(BaseModel):
        blocker_ids: list = []

    class CommentIn(BaseModel):
        body: str

    @r.get("/api/me")
    def whoami(request: Request):
        return {"email": me(request).email}

    @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: Title, request: Request):
        return run(boards.create_board, me(request), body.title)

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

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

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

    @r.delete("/api/boards/{board_id}/members/{email}")
    def remove_member(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: Name, request: Request):
        return run(boards.add_list, me(request), board_id, body.name)

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

    @r.delete("/api/boards/{board_id}/lists/{list_id}")
    def remove_list(board_id: str, list_id: str, request: Request):
        return run(boards.remove_list, me(request), board_id, list_id)

    @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,
                   list_name=body.list_name, list_id=body.list_id,
                   description=body.description, assignee=body.assignee,
                   due_date=_parse_date(body.due_date), labels=body.labels)

    @r.patch("/api/cards/{card_id}")
    def update_card(card_id: str, body: CardPatch, request: Request):
        fields = {}
        if body.title is not None:
            fields["title"] = body.title
        if body.description is not None:
            fields["description"] = body.description
        if body.assignee is not None:
            fields["assignee"] = body.assignee
        if body.due_date is not None:
            fields["due_date"] = _parse_date(body.due_date)
        if body.labels is not None:
            fields["labels"] = body.labels
        return run(boards.update_card, me(request), card_id, **fields)

    @r.post("/api/cards/{card_id}/move")
    def move_card(card_id: str, body: Move, request: Request):
        return run(boards.move_card, me(request), card_id, list_name=body.list_name,
                   list_id=body.list_id, index=body.index)

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

    @r.post("/api/cards/{card_id}/blockers")
    def set_blockers(card_id: str, body: Blockers, request: Request):
        return run(boards.set_blockers, me(request), card_id, body.blocker_ids)

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

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

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

    @r.post("/api/cards/{card_id}/comments", status_code=201)
    def add_comment(card_id: str, body: CommentIn, request: Request):
        return run(boards.add_comment, me(request), card_id, body.body)

    @r.post("/api/cards/{card_id}/checklist")
    def suggest_checklist(card_id: str, request: Request):
        return run(boards.suggest_checklist, me(request), card_id)

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

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

    @r.get("/api/my-cards")
    def my_cards(request: Request, days: int = 7):
        return run(boards.my_cards, me(request), days)

    @r.get("/api/overdue")
    def overdue(request: Request):
        return run(boards.overdue_cards, me(request))

    @r.get("/api/search")
    def search(request: Request, q: str = "", semantic: int = 1):
        return run(boards.search_cards, me(request), q, bool(semantic))

    @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