sirenamc/stackline

Stackline, a team task board you run from the web or from Claude/ChatGPT. FastAPI + DataK3 + appid + Models + MCP.

A remix template · 0 remixes · Remix sirenamc/stackline

app/boards/doors/web.py

"""The web door: JSON routes plus one page. Every route calls a use case and nothing else."""
import html
import os
from pathlib import Path
from typing import Optional

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

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

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


def demo_block(accounts: tuple, password: str) -> str:
    """The "Try it" box of the front page. A copy that runs the public demo lists its three
    accounts; any other copy (a fresh remix) points to the example board instead."""
    if len(accounts) == 3 and password:
        rows = "".join(f"<tr><td>{role}</td><td><code>{html.escape(email)}</code></td></tr>"
                       for role, email in zip(("Owner", "Member", "Viewer"), accounts))
        return (f"<h3>Sign in as anyone on the team</h3><p>Sign in with any of these. The password for all three is "
                f"<code>{html.escape(password)}</code>.</p><table class=\"logins\">{rows}</table>"
                "<ol class=\"checks\"><li>As the <b>viewer</b>: you see Launch but not Ops, "
                "and no card moves.</li><li>As the <b>member</b>: drag a card to Done, then "
                "open Activity.</li><li>As the <b>owner</b>: open Members and change a role.</li></ol>"
                "<p class=\"muted\">Someone made a mess? \"Reset the demo\" at the top of the "
                "board puts it back.</p>")
    return ("<h3>Start with the example board</h3><p>Sign in, then press <b>Open the example board</b>: a website launch with "
            "nine cards, two of them late, and a chain of blockers.</p><ol class=\"checks\">"
            "<li>Invite a second account as a viewer and see that it can't move a card.</li>"
            "<li>Draft a board from one sentence with Dodil Models.</li>"
            "<li>Connect Claude and ask what is blocking the launch.</li></ol>")


def build_router(boards: Boards) -> APIRouter:
    r = APIRouter()
    landing = LANDING.replace("<!--DEMO-->",
                              demo_block(boards.demo, os.environ.get("DEMO_PASSWORD", "")))

    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):
        person = me(request)
        try:
            return fn(person, *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))

    class Title(BaseModel):
        title: str

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

    class CardIn(BaseModel):
        title: str
        list_name: str = ""
        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
        due_date: Optional[str] = None
        labels: Optional[list[str]] = None

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

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

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

    class Blocker(BaseModel):
        blocked_by: str

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

    class Check(BaseModel):
        done: bool = True

    class Plan(BaseModel):
        title: str
        goal: str = ""
        deadline: Optional[str] = None
        people: list[str] = []
        steps: Optional[list[dict]] = None

    @r.get("/api/me")
    def whoami(request: Request):
        email = me(request).email
        return {"email": email, "demo": email in boards.demo}

    @r.post("/api/boards/plan", status_code=201)
    def set_up_board(body: Plan, request: Request):
        return run(boards.set_up_board, request, body.title, body.goal, body.deadline,
                   body.people, body.steps)

    @r.post("/api/example", status_code=201)
    def start_example(request: Request):
        return run(boards.start_example, request)

    @r.post("/api/demo/reset")
    def reset_demo(request: Request):
        return run(boards.reset_demo, request)

    @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}")
    def show_board(board: str, request: Request):
        return run(boards.show_board, request, board)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    @r.post("/api/cards/{card}/checklist/{index}")
    def check_item(card: str, index: int, body: Check, request: Request):
        return run(boards.check_item, request, card, index, body.done)

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

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

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

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

    @r.get("/", response_class=HTMLResponse)
    def page(request: Request):
        """Signed in: the board. Not signed in: the front page, which says what this is and
        links to the pool's sign-in. It shows no data, so it needs no identity."""
        return PAGE if person_from(request.headers) else landing

    return r