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