theaio/theaio-boards

Laneway, team task boards you can run from Claude or ChatGPT. FastAPI + DataK3 + appid + Models + MCP.

A remix template · 0 remixes · Remix theaio/theaio-boards

app/boards/domain/model.py

"""The board's language and rules. No database, no HTTP: plain Python you can test in a second.

Extend HERE first: a rule written in the domain holds for the web page and for Claude alike.
"""
import re
from dataclasses import dataclass, field
from datetime import date, datetime, timedelta
from enum import Enum
from typing import Iterable, Optional

DEFAULT_LISTS = ("To do", "Doing", "Done")
MAX_TITLE, MAX_TEXT = 200, 5000
MAX_LABELS, MAX_LABEL = 8, 24
EMAIL = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")


class Role(str, Enum):
    OWNER = "owner"
    MEMBER = "member"
    VIEWER = "viewer"


class Forbidden(Exception):
    """The person may not do this. The doors turn it into 403 (web) or a tool error (MCP)."""


class Invalid(Exception):
    """The request breaks a rule of the board itself. 422 / tool error."""


@dataclass(frozen=True)
class Person:
    """Who is acting: taken from the gateway's sign-in, never from the request body."""
    email: str


@dataclass(frozen=True)
class Change:
    """One field of a card going from old to new: what the history records."""
    field: str
    old: str
    new: str


@dataclass(frozen=True)
class HistoryEntry:
    id: str
    board_id: str
    card_id: str
    actor: str
    at: datetime
    field: str
    old: str
    new: str


def _text(value) -> str:
    """How a value reads in the history: dates as ISO, labels comma-joined, nothing as ''."""
    if value is None:
        return ""
    if isinstance(value, date):
        return value.isoformat()
    if isinstance(value, (list, tuple)):
        return ", ".join(value)
    return str(value)


def clean_title(title: str, what: str = "card") -> str:
    title = " ".join((title or "").split())
    if not title:
        raise Invalid(f"a {what} needs a title")
    if len(title) > MAX_TITLE:
        raise Invalid(f"keep the {what} title under {MAX_TITLE} characters")
    return title


def clean_labels(labels: Iterable[str]) -> list:
    out, seen = [], set()
    for raw in labels or []:
        label = " ".join(str(raw).split())
        if not label or label.lower() in seen:
            continue
        if len(label) > MAX_LABEL:
            raise Invalid(f"label {label!r} is too long (max {MAX_LABEL} characters)")
        seen.add(label.lower())
        out.append(label)
    if len(out) > MAX_LABELS:
        raise Invalid(f"a card takes at most {MAX_LABELS} labels")
    return out


def clean_email(email: str) -> str:
    email = (email or "").strip().lower()
    if not EMAIL.match(email):
        raise Invalid(f"{email!r} is not an email address")
    return email


def parse_due(text, today: date) -> Optional[date]:
    """A due date as people say it: 2026-11-30, today, tomorrow, +3 / in 3 days; '' clears it."""
    if text is None or isinstance(text, date):
        return text
    t = str(text).strip().lower()
    if t in ("", "none", "clear", "no date"):
        return None
    if t == "today":
        return today
    if t == "tomorrow":
        return today + timedelta(days=1)
    m = re.fullmatch(r"(?:\+|in )?(\d{1,3})(?: ?days?)?", t)
    if m and (t.startswith("+") or t.startswith("in ")):
        return today + timedelta(days=int(m.group(1)))
    try:
        return date.fromisoformat(t)
    except ValueError:
        raise Invalid(f"can't read the date {text!r}: use YYYY-MM-DD, today, tomorrow or +N days")


def reorder(items: list, item, index: Optional[int]) -> list:
    """Put item at index (end when None) and renumber every pos from 0. Returns the new order."""
    rest = [i for i in items if i is not item]
    at = len(rest) if index is None else max(0, min(int(index), len(rest)))
    rest.insert(at, item)
    for pos, i in enumerate(rest):
        i.pos = pos
    return rest


@dataclass
class BoardList:
    id: str
    board_id: str
    name: str
    pos: int = 0


@dataclass
class Card:
    id: str                  # board_id + ':' + number: the number is claimed by the primary key
    board_id: str
    number: int
    list_id: str
    title: str
    description: str = ""
    assignee: Optional[str] = None
    due_date: Optional[date] = None
    labels: list = field(default_factory=list)
    pos: int = 0
    created_by: str = ""

    EDITABLE = ("title", "description", "assignee", "due_date", "labels")

    def update(self, **values) -> list:
        """Set fields and say what changed. Unchanged fields record nothing."""
        changes = []
        for name, value in values.items():
            if name not in self.EDITABLE:
                raise Invalid(f"a card has no field {name!r}")
            if name == "title":
                value = clean_title(value)
            elif name == "description":
                value = (value or "").strip()
                if len(value) > MAX_TEXT:
                    raise Invalid(f"keep the description under {MAX_TEXT} characters")
            elif name == "labels":
                value = clean_labels(value)
            elif name == "assignee":
                value = value or None
            old = getattr(self, name)
            if old != value:
                setattr(self, name, value)
                changes.append(Change(name, _text(old), _text(value)))
        return changes

    def is_overdue(self, today: date, done_list_id: str) -> bool:
        return bool(self.due_date and self.due_date < today and self.list_id != done_list_id)


def blocking_path(links: Iterable[tuple], card_id: str, blocker_id: str) -> Optional[list]:
    """links are (card, blocked_by) pairs. Adding card <- blocker closes a cycle when card already
    (directly or through others) blocks blocker. Returns that chain, or None when it is safe."""
    if card_id == blocker_id:
        return [card_id]
    blocked_by = {}
    for c, b in links:
        blocked_by.setdefault(c, []).append(b)
    stack, seen = [(blocker_id, [blocker_id])], set()
    while stack:
        node, path = stack.pop()
        if node == card_id:
            return path
        if node in seen:
            continue
        seen.add(node)
        stack.extend((n, path + [n]) for n in blocked_by.get(node, []))
    return None


@dataclass
class Board:
    id: str
    title: str
    owner_email: str
    members: dict = field(default_factory=dict)  # email -> Role (the owner is not in here)
    lists: list = field(default_factory=list)    # BoardList, any order; .ordered() sorts them

    @classmethod
    def new(cls, board_id: str, title: str, owner: Person, list_ids: Iterable[str]) -> "Board":
        board = cls(id=board_id, title=clean_title(title, "board"), owner_email=owner.email)
        board.lists = [BoardList(i, board_id, name, pos)
                       for pos, (i, name) in enumerate(zip(list_ids, DEFAULT_LISTS))]
        return board

    # --- who may do what -------------------------------------------------------------------
    def role_of(self, person: Person) -> Optional[Role]:
        if person.email == self.owner_email:
            return Role.OWNER
        return self.members.get(person.email)

    def require_view(self, person: Person) -> None:
        if self.role_of(person) is None:
            raise Forbidden("you are not on this board")

    def require_edit(self, person: Person) -> None:
        role = self.role_of(person)
        if role is None:
            raise Forbidden("you are not on this board")
        if role is Role.VIEWER:
            raise Forbidden("viewers can look but can't change the board; ask the owner")

    def require_owner(self, person: Person) -> None:
        self.require_view(person)
        if self.role_of(person) is not Role.OWNER:
            raise Forbidden("only the board's owner manages members")

    # --- people ----------------------------------------------------------------------------
    def people(self) -> list:
        return [(self.owner_email, Role.OWNER)] + sorted(self.members.items())

    def invite(self, by: Person, email: str, role: Role) -> str:
        self.require_owner(by)
        email = clean_email(email)
        if role is Role.OWNER:
            raise Invalid("a board has one owner; invite as 'member' or 'viewer'")
        if email == self.owner_email or email in self.members:
            raise Invalid(f"{email} is already on this board; use set_member_role to change it")
        self.members[email] = role
        return email

    def set_role(self, by: Person, email: str, role: Role) -> str:
        self.require_owner(by)
        email = (email or "").strip().lower()
        if email == self.owner_email:
            raise Invalid("the owner's role can't change")
        if email not in self.members:
            raise Invalid(f"{email} is not on this board")
        if role is Role.OWNER:
            raise Invalid("a board has one owner; choose 'member' or 'viewer'")
        self.members[email] = role
        return email

    def remove(self, by: Person, email: str) -> str:
        self.require_owner(by)
        email = (email or "").strip().lower()
        if email == self.owner_email:
            raise Invalid("the owner can't be removed from their board")
        if email not in self.members:
            raise Invalid(f"{email} is not on this board")
        del self.members[email]
        return email

    def require_assignable(self, email: str) -> None:
        role = self.role_of(Person(email))
        if role is None:
            raise Invalid(f"{email} is not on this board; the owner can invite them first")
        if role is Role.VIEWER:
            raise Invalid(f"{email} is a viewer; only owners and members can be assigned")

    # --- lists -----------------------------------------------------------------------------
    def ordered(self) -> list:
        return sorted(self.lists, key=lambda l: (l.pos, l.name))

    @property
    def done_list(self) -> BoardList:
        """The last list counts as done: its cards are never overdue nor blocking."""
        return self.ordered()[-1]

    def find_list(self, ref: str) -> BoardList:
        key = (ref or "").strip().lower()
        for lst in self.lists:
            if lst.id == ref or lst.name.lower() == key:
                return lst
        names = ", ".join(l.name for l in self.ordered())
        raise Invalid(f"no list {ref!r} on {self.title}; lists are {names}")

    def _unique_name(self, name: str, keep: Optional[BoardList] = None) -> str:
        name = clean_title(name, "list")
        if any(l is not keep and l.name.lower() == name.lower() for l in self.lists):
            raise Invalid(f"{self.title} already has a list called {name!r}")
        return name

    def add_list(self, by: Person, list_id: str, name: str) -> BoardList:
        """A new list goes just before the last one, so the done list stays last."""
        self.require_edit(by)
        lst = BoardList(list_id, self.id, self._unique_name(name), len(self.lists))
        self.lists = reorder(self.ordered(), lst, max(len(self.lists) - 1, 0))
        return lst

    def rename_list(self, by: Person, lst: BoardList, name: str) -> str:
        self.require_edit(by)
        old, lst.name = lst.name, self._unique_name(name, keep=lst)
        return old

    def move_list(self, by: Person, lst: BoardList, index: int) -> None:
        self.require_edit(by)
        self.lists = reorder(self.ordered(), lst, index)

    # --- cards -----------------------------------------------------------------------------
    def edit_card(self, by: Person, card: Card, **values) -> list:
        self.require_edit(by)
        if values.get("assignee"):
            values["assignee"] = values["assignee"].strip().lower()
            self.require_assignable(values["assignee"])
        return card.update(**values)

    def move_card(self, by: Person, card: Card, to: BoardList, siblings: list,
                  index: Optional[int]) -> list:
        """Move card into list `to` at index among `siblings` (the cards already in it, in order).
        Returns what changed for the history; every sibling's pos is renumbered."""
        self.require_edit(by)
        before_list = next((l.name for l in self.lists if l.id == card.list_id), "")
        same_list = card.list_id == to.id
        before = next((i for i, c in enumerate(siblings) if c is card), None)
        card.list_id = to.id
        reorder(siblings, card, index)
        if not same_list:
            return [Change("list", before_list, to.name)]
        if before is not None and before != card.pos:
            return [Change("position", str(before + 1), str(card.pos + 1))]
        return []