flc/crewboard

Crewboard - team task board (boards, lists, cards, roles, history, drag and drop, MCP, AI extras). FastAPI + DataK3 + appid + Models.

A remix template · 1 remixes · Remix flc/crewboard

app/boards/doors/web.py

"""The web door: JSON routes plus one page. Every route calls a use case and nothing else."""
from pathlib import Path
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.domain.model import Conflict, Forbidden, Invalid, Person

PAGE = (Path(__file__).parent / "page.html").read_text(encoding="utf-8")


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, request: Request, *args, **kwargs):
        try:
            return fn(me(request), *args, **kwargs)
        except NotFound as e:
            raise HTTPException(404, str(e))
        except Forbidden as e:
            raise HTTPException(403, str(e))
        except Conflict as e:
            raise HTTPException(409, str(e))
        except Invalid as e:
            raise HTTPException(422, str(e))

    def version(request: Request) -> Optional[int]:
        """If-Match: <version> makes an edit conditional (409 when someone else wrote first)."""
        raw = (request.headers.get("if-match") or "").strip('" W/')
        return int(raw) if raw.isdigit() else None

    class Title(BaseModel):
        title: str

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

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

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

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

    class ListIn(BaseModel):
        name: str
        before: Optional[str] = None

    class ListRename(BaseModel):
        new: str

    class Block(BaseModel):
        blocked_by: str

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

    # --- me and my work ----------------------------------------------------------------------
    @r.get("/api/me")
    def whoami(request: Request):
        return {"email": me(request).email}

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

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

    @r.get("/api/search")
    def search(q: str, request: Request, board_id: Optional[str] = None):
        return run(boards.find_cards, request, q, board_id)

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

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

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

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

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

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

    @r.get("/api/boards/{board_id}/members")
    def people(board_id: str, request: Request):
        return run(boards.people, 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, 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, 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, request, board_id, body.name, body.before)

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

    @r.delete("/api/boards/{board_id}/lists/{name}")
    def remove_list(board_id: str, name: str, request: Request):
        return run(boards.remove_list, request, board_id, 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, request, board_id, body.title, body.list_name,
                   body.description, body.assignee, body.due_date, body.labels)

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

    @r.patch("/api/cards/{card_id}")
    def update_card(card_id: str, body: CardEdit, request: Request):
        fields = body.model_dump(exclude_unset=True)
        return run(boards.update_card, request, card_id, version(request), **fields)

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

    @r.post("/api/cards/{card_id}/archive")
    def archive_card(card_id: str, request: Request, restore: bool = False):
        return run(boards.archive_card, request, card_id, not restore)

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

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

    @r.post("/api/cards/{card_id}/blockers", status_code=201)
    def block_card(card_id: str, body: Block, request: Request):
        return run(boards.block_card, request, card_id, body.blocked_by)

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

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

    @r.post("/api/cards/{card_id}/checklist/apply")
    def apply_checklist(card_id: str, body: Items, request: Request):
        return run(boards.apply_checklist, request, card_id, body.items)

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

    @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