dmslanes/lanes

Lanes - team task boards with roles, history and an MCP endpoint for Claude and ChatGPT

A remix template · 0 remixes · Remix dmslanes/lanes

app/web/routes.py

"""HTTP door: thin FastAPI routes over the same use cases the MCP tools call."""
from __future__ import annotations

from datetime import date
from pathlib import Path

from fastapi import APIRouter, Depends, Request
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field

from app.application.use_cases import BoardService
from app.application.views import (
    activity_view, board_view, card_view, cards_with_context, full_board_view, list_view, member_view,
)
from app.infrastructure.identity import email_from_headers

STATIC = Path(__file__).parent / "static"


class NewWorkspace(BaseModel):
    name: str = Field(max_length=200)

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

class NewBoard(BaseModel):
    workspace_id: str
    name: str = Field(max_length=200)

class NewList(BaseModel):
    name: str = Field(max_length=200)

class NewCard(BaseModel):
    list: str
    title: str = Field(max_length=200)
    description: str = Field("", max_length=5000)
    assignee: str | None = None
    due_date: date | None = None
    labels: list[str] = []

class CardEdit(BaseModel):
    title: str | None = Field(None, max_length=200)
    description: str | None = Field(None, max_length=5000)
    due_date: date | None = None
    labels: list[str] | None = None
    clear_due_date: bool = False
    version: int | None = None

class Move(BaseModel):
    to_list: str
    position: int | None = None

class Assign(BaseModel):
    assignee: str | None = None

class Block(BaseModel):
    blocked_by: str


def build_router(svc: BoardService) -> APIRouter:
    r = APIRouter()

    def me(request: Request) -> str:
        return email_from_headers(request.headers)

    @r.get("/", include_in_schema=False)
    def index():
        return FileResponse(STATIC / "index.html")

    @r.get("/api/me")
    def whoami(actor: str = Depends(me)):
        return {"email": actor, "workspaces": [{"id": ws.id, "name": ws.name, "role": role.value}
                                               for ws, role in svc.list_workspaces(actor)]}

    @r.post("/api/workspaces", status_code=201)
    def create_workspace(body: NewWorkspace, actor: str = Depends(me)):
        ws = svc.create_workspace(actor, body.name)
        return {"id": ws.id, "name": ws.name, "role": "owner"}

    @r.get("/api/workspaces/{workspace_id}/members")
    def members(workspace_id: str, actor: str = Depends(me)):
        return [member_view(m) for m in svc.list_members(actor, workspace_id)]

    @r.post("/api/workspaces/{workspace_id}/members", status_code=201)
    def invite(workspace_id: str, body: Invite, actor: str = Depends(me)):
        return member_view(svc.invite_member(actor, workspace_id, body.email, body.role))

    @r.delete("/api/workspaces/{workspace_id}/members/{email}", status_code=204)
    def remove(workspace_id: str, email: str, actor: str = Depends(me)):
        svc.remove_member(actor, workspace_id, email)

    @r.get("/api/boards")
    def boards(actor: str = Depends(me)):
        return [board_view(b) for b in svc.list_boards(actor)]

    @r.post("/api/boards", status_code=201)
    def create_board(body: NewBoard, actor: str = Depends(me)):
        return board_view(svc.create_board(actor, body.workspace_id, body.name))

    @r.get("/api/boards/{board_id}")
    def board(board_id: str, actor: str = Depends(me)):
        return full_board_view(svc.get_board(actor, board_id))

    @r.post("/api/boards/{board_id}/lists", status_code=201)
    def add_list(board_id: str, body: NewList, actor: str = Depends(me)):
        return list_view(svc.add_list(actor, board_id, body.name))

    @r.get("/api/boards/{board_id}/activity")
    def board_activity(board_id: str, actor: str = Depends(me)):
        return [activity_view(a) for a in svc.board_activity(actor, board_id)]

    @r.post("/api/boards/{board_id}/cards", status_code=201)
    def add_card(board_id: str, body: NewCard, actor: str = Depends(me)):
        return card_view(svc.add_card(actor, board_id, body.list, body.title, body.description,
                                      body.assignee, body.due_date, body.labels))

    @r.get("/api/cards/{ref}")
    def card(ref: str, actor: str = Depends(me)):
        return cards_with_context(svc, [svc.get_card(actor, ref)])[0]

    @r.patch("/api/cards/{ref}")
    def edit_card(ref: str, body: CardEdit, actor: str = Depends(me)):
        fields = {k: v for k, v in body.model_dump(exclude={"version", "clear_due_date"}).items() if v is not None}
        if body.clear_due_date:
            fields["due_date"] = None
        return card_view(svc.update_card(actor, ref, expected_version=body.version, **fields))

    @r.post("/api/cards/{ref}/move")
    def move(ref: str, body: Move, actor: str = Depends(me)):
        return card_view(svc.move_card(actor, ref, body.to_list, body.position))

    @r.post("/api/cards/{ref}/assign")
    def assign(ref: str, body: Assign, actor: str = Depends(me)):
        return card_view(svc.assign_card(actor, ref, body.assignee))

    @r.delete("/api/cards/{ref}", status_code=204)
    def delete(ref: str, actor: str = Depends(me)):
        svc.delete_card(actor, ref)

    @r.get("/api/cards/{ref}/history")
    def history(ref: str, actor: str = Depends(me)):
        return [activity_view(a) for a in svc.card_history(actor, ref)]

    @r.post("/api/cards/{ref}/blocked-by")
    def block(ref: str, body: Block, actor: str = Depends(me)):
        return card_view(svc.block_card(actor, ref, body.blocked_by))

    @r.delete("/api/cards/{ref}/blocked-by/{blocker}")
    def unblock(ref: str, blocker: str, actor: str = Depends(me)):
        return card_view(svc.unblock_card(actor, ref, blocker))

    @r.get("/api/cards/{ref}/blockers")
    def blockers(ref: str, actor: str = Depends(me)):
        return cards_with_context(svc, svc.blockers(actor, ref))

    @r.post("/api/cards/{ref}/checklist")
    def checklist(ref: str, actor: str = Depends(me)):
        return card_view(svc.card_to_checklist(actor, ref))

    @r.get("/api/cards/{ref}/similar")
    def similar(ref: str, actor: str = Depends(me)):
        pairs = svc.similar_cards(actor, ref)
        return [{**v, "similarity": round(score, 3)}
                for v, (_, score) in zip(cards_with_context(svc, [c for c, _ in pairs]), pairs)]

    @r.get("/api/boards/{board_id}/summary")
    def summary(board_id: str, actor: str = Depends(me)):
        return {"summary": svc.board_summary(actor, board_id)}

    @r.get("/api/my-cards")
    def my_cards(days: int | None = None, actor: str = Depends(me)):
        return cards_with_context(svc, svc.my_cards(actor, days))

    @r.get("/api/overdue")
    def overdue(board_id: str | None = None, actor: str = Depends(me)):
        return cards_with_context(svc, svc.overdue(actor, board_id))

    @r.get("/api/search")
    def search(q: str, actor: str = Depends(me)):
        return cards_with_context(svc, svc.search_cards(actor, q))

    return r