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