rgboards/rg-boards

Lanes, team task boards you run from the web or from Claude/ChatGPT (MCP). FastAPI + DataK3 + appid + Dodil Models.

A remix template · 0 remixes · Remix rgboards/rg-boards

app/boards/doors/web.py

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

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

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

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

# Shared demo accounts exist only in the author's pool. A remix has its own pool, so its landing
# page shows a "first sign-up" note instead of logins that would not work there.
DEMO_ACCOUNTS = (("owner", "demo.owner@lanes-demo.dev",
                  "Owns Product Launch and Ops. Invites people, edits everything."),
                 ("member", "demo.member@lanes-demo.dev",
                  "Edits cards on Product Launch and Ops, owns Website Refresh."),
                 ("viewer", "demo.viewer@lanes-demo.dev",
                  "Read-only on two boards and can't see Ops at all. Try to drag a card."))
DEMO_PASSWORD = "LanesDemo-2026!"  # a public demo password for throwaway demo accounts


def landing_page() -> str:
    pool, demo_pool = os.environ.get("DODIL_USER_POOL", ""), os.environ.get("DEMO_POOL", "")
    if pool and pool == demo_pool:
        cards = "".join(
            f'<div class="acct"><span class="r {role}">{role}</span>'
            f'<code>{email}</code><p>{note}</p>'
            f'<button class="copy" data-copy="{email}">copy email</button></div>'
            for role, email, note in DEMO_ACCOUNTS)
        block = (f'<section class="demo"><div class="wrap"><h2>Try it in 30 seconds</h2>'
                 f'<p class="small" style="margin:0 0 1.2rem">Sign in with any of these demo '
                 f'accounts to see the same boards from three different roles.</p>'
                 f'<div class="cards">{cards}</div><div class="pw">Password for all three: '
                 f'<code>{DEMO_PASSWORD}</code> <button class="copy" data-copy="{DEMO_PASSWORD}">'
                 f'copy</button></div></div></section>')
    else:
        block = ('<section class="demo"><div class="wrap"><h2>Your team\'s boards start here'
                 '</h2><p class="small" style="margin:0">Sign in (or create an account) and '
                 'make a board. Whoever creates a board owns it and invites the rest of the '
                 'team as members or viewers.</p></div></section>')
    return LANDING.replace("{{DEMO}}", block)


class Title(BaseModel):
    title: str


class Name(BaseModel):
    name: str


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


class CardPatch(BaseModel):
    title: Optional[str] = None
    description: Optional[str] = None
    due_date: Optional[str] = None
    labels: Union[list[str], str, None] = None
    version: Optional[int] = None


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


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


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


class Blocker(BaseModel):
    blocked_by: Union[str, int]


class Items(BaseModel):
    items: Union[list[str], str, None] = None


class Tick(BaseModel):
    done: bool = True


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 Invalid as e:
            raise HTTPException(422, str(e))
        except Conflict as e:
            raise HTTPException(409, str(e))
        except Unavailable as e:
            raise HTTPException(503, str(e))

    # ---------------------------------------------------------------- who am I
    @r.get("/api/me")
    def whoami(request: Request):
        return {"email": me(request).email}

    # ---------------------------------------------------------------- boards
    @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):
        return run(boards.show_board, request, board_id)

    @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.post("/api/boards/{board_id}/lists", status_code=201)
    def add_list(board_id: str, body: Name, request: Request):
        return run(boards.add_list, request, board_id, body.name)

    @r.delete("/api/boards/{board_id}/lists/{name:path}")  # a list may be called "Q3/Q4"
    def remove_list(board_id: str, name: str, request: Request):
        return run(boards.remove_list, request, board_id, name)

    @r.get("/api/boards/{board_id}/members")
    def members(board_id: str, request: Request):
        return run(boards.members, 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.get("/api/boards/{board_id}/activity")
    def activity(board_id: str, request: Request, limit: int = 50):
        return run(boards.board_activity, request, board_id, limit)

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

    @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)

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

    @r.patch("/api/cards/{card_id}")
    def update_card(card_id: str, body: CardPatch, request: Request):
        return run(boards.update_card, request, card_id, None, body.title, body.description,
                   body.due_date, body.labels, body.version)

    @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, None, body.before_card)

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

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

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

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

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

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

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

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

    @r.post("/api/cards/{card_id}/checklist/{item_id}")
    def tick(card_id: str, item_id: str, body: Tick, request: Request):
        return run(boards.tick_item, request, card_id, item_id, body.done)

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

    # ---------------------------------------------------------------- across boards
    @r.get("/api/my-cards")
    def my_cards(request: Request, days: Optional[int] = None):
        return run(boards.my_cards, request, days)

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

    @r.get("/api/blocked")
    def blocked(request: Request, board: Optional[str] = None):
        return run(boards.blocked_cards, 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("/api/similar")
    def similar_text(text: str, request: Request):
        return run(boards.find_similar, request, None, text)

    # ---------------------------------------------------------------- the page
    @r.get("/", response_class=HTMLResponse)
    def page(request: Request):
        if person_from(request.headers) is None:
            return landing_page()   # signed out: what Lanes is, how to sign in, demo logins
        return PAGE

    return r