north-scape/relayboard

Relayboard: accessible team boards with audited actions, dependency maps, and shared web/MCP use cases.

A remix template · 0 remixes · Remix north-scape/relayboard

app/boards/doors/web.py

"""Authenticated JSON routes + a zero-build, accessible web client."""
from pathlib import Path
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import FileResponse, RedirectResponse
from pydantic import BaseModel, ConfigDict, Field, StrictInt
from boards.application.use_cases import NotFound
from boards.doors.identity import person_from
from boards.domain.model import Conflict, Forbidden, Invalid

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

class Input(BaseModel):
    model_config=ConfigDict(extra='forbid')
class Title(Input): title: str
class CardIn(Input):
    title: str
    list_name: str='To do'
    description: str=''
    assignee: str | None=None
    due_date: str | None=None
    labels: list[str]=Field(default_factory=list)
class Patch(Input):
    title: str | None=None
    description: str | None=None
    assignee: str | None=None
    due_date: str | None=None
    labels: list[str] | None=None
    checklist: list[dict] | None=None
    expected_revision: StrictInt
class Move(Input):
    list_name: str
    expected_revision: StrictInt
class Member(Input):
    email: str
    role: str='member'
class RemoveMember(Input):
    email: str
class Blockers(Input):
    blocked_by: list[str]
    expected_revision: StrictInt


def build_router(boards):
    r=APIRouter()
    def me(request):
        p=person_from(request.headers)
        if p is None: raise HTTPException(401,'sign in first: /.dodil/auth/login')
        return p
    def run(fn,*args,**kwargs):
        try: return fn(*args,**kwargs)
        except NotFound as e: raise HTTPException(404,str(e)) from None
        except Forbidden as e: raise HTTPException(403,str(e)) from None
        except Invalid as e: raise HTTPException(422,str(e)) from None
        except Conflict as e: raise HTTPException(409,str(e)) from None

    @r.get('/api/me')
    def identity(request: Request): return {'email':me(request).email}
    @r.get('/api/boards')
    def my_boards(request: Request): return run(boards.my_boards,me(request))
    @r.get('/api/resolve/boards')
    def resolve_board(reference: str,request: Request): return run(boards.resolve_board,me(request),reference)
    @r.get('/api/resolve/cards')
    def resolve_card(reference: str,request: Request,board_id: str | None=None):
        return run(boards.resolve_card,me(request),reference,board_id)
    @r.post('/api/boards',status_code=201)
    def create_board(body: Title,request: Request): return run(boards.create_board,me(request),body.title)
    @r.get('/api/boards/{board_id}')
    def show_board(board_id: str,request: Request): return run(boards.show_board,me(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,me(request),board_id,body.email,body.role)
    @r.delete('/api/boards/{board_id}/members')
    def remove_member(board_id: str,body: RemoveMember,request: Request): return run(boards.remove_member,me(request),board_id,body.email)
    @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,me(request),board_id,**body.model_dump())
    @r.patch('/api/cards/{card_id}')
    def update_card(card_id: str,body: Patch,request: Request):
        changes=body.model_dump(exclude_unset=True,exclude={'expected_revision'})
        return run(boards.update_card,me(request),card_id,changes,body.expected_revision)
    @r.post('/api/cards/{card_id}/move')
    def move_card(card_id: str,body: Move,request: Request): return run(boards.move_card,me(request),card_id,body.list_name,body.expected_revision)
    @r.get('/api/boards/{board_id}/activity')
    def activity(board_id: str,request: Request): return run(boards.activity,me(request),board_id)
    @r.get('/api/my-cards')
    def mine(request: Request,days: int=7): return run(boards.my_cards,me(request),days)
    @r.get('/api/overdue')
    def overdue(request: Request): return run(boards.overdue,me(request))
    @r.post('/api/cards/{card_id}/checklist')
    def checklist(card_id: str,request: Request): return run(boards.suggest_checklist,me(request),card_id)
    @r.post('/api/cards/{card_id}/blockers')
    def set_blockers(card_id: str,body: Blockers,request: Request): return run(boards.set_blockers,me(request),card_id,body.blocked_by,body.expected_revision)
    @r.get('/api/cards/{card_id}/blockers')
    def blockers(card_id: str,request: Request): return run(boards.blockers,me(request),card_id)
    @r.post('/api/boards/{board_id}/summary')
    def summary(board_id: str,request: Request): return run(boards.board_summary,me(request),board_id)
    @r.post('/api/cards/{card_id}/similar')
    def similar(card_id: str,request: Request): return run(boards.similar_cards,me(request),card_id)
    @r.get('/')
    def page(request: Request):
        if person_from(request.headers) is None: return RedirectResponse('/.dodil/auth/login?return_to=/',303)
        return FileResponse(STATIC/'index.html')
    @r.get('/static/{filename}')
    def asset(filename: str,request: Request):
        me(request)
        if filename not in ('app.js','style.css'): raise HTTPException(404)
        return FileResponse(STATIC/filename)
    return r