akif/arclet-boards

Arclet Boards — private Kanban workspaces with AppID, DataK3, shared web and MCP actions.

A remix template · 1 remixes · Remix akif/arclet-boards

app/domain/model.py

"""Entities, validation and access rules, independent of HTTP and storage."""
from dataclasses import dataclass
from datetime import date, datetime, timezone
from enum import Enum
import re


class AppError(Exception):
    status = 422


class Unauthenticated(AppError):
    status = 401


class NotFound(AppError):
    status = 404


class Forbidden(AppError):
    status = 403


class Conflict(AppError):
    status = 409


class Unavailable(AppError):
    status = 503


class Invalid(AppError):
    pass


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


@dataclass(frozen=True)
class Person:
    sub: str
    email: str


@dataclass
class Workspace:
    id: str
    title: str
    owner_sub: str
    owner_email: str
    created_at: str
    updated_at: str
    version: int = 1


@dataclass
class WorkspaceMembership:
    id: str
    workspace_id: str
    email: str
    user_sub: str | None
    role: str
    invited_by: str
    created_at: str
    updated_at: str


@dataclass
class Board:
    id: str
    workspace_id: str
    title: str
    description: str
    color: str
    owner_sub: str
    owner_email: str
    created_at: str
    updated_at: str
    version: int = 1


@dataclass
class BoardMembership:
    id: str
    board_id: str
    email: str
    user_sub: str | None
    role: str
    invited_by: str
    created_at: str
    updated_at: str


@dataclass
class BoardList:
    id: str
    board_id: str
    title: str
    position: int
    is_done: bool
    created_at: str
    updated_at: str
    version: int = 1


@dataclass
class Card:
    id: str
    board_id: str
    list_id: str
    title: str
    description: str
    assignee: str | None
    due_date: str | None
    position: int
    created_by: str
    created_at: str
    updated_at: str
    version: int = 1


@dataclass
class CardLabel:
    id: str
    board_id: str
    card_id: str
    name: str
    color: str
    created_at: str


@dataclass
class CardActivity:
    id: str
    board_id: str
    card_id: str
    actor_sub: str
    actor_email: str
    action: str
    changes_json: str
    created_at: str


@dataclass
class CardChecklistItem:
    id: str
    board_id: str
    card_id: str
    title: str
    completed: bool
    position: int
    created_at: str
    updated_at: str
    version: int = 1


@dataclass
class CardDependency:
    id: str
    board_id: str
    card_id: str
    depends_on_id: str
    created_by: str
    created_at: str


@dataclass
class CardEmbedding:
    id: str
    board_id: str
    card_id: str
    content_hash: str
    model: str
    dimensions: int
    embedding: str
    updated_at: str


COLORS = ("indigo", "blue", "teal", "amber", "rose", "violet", "slate")


def now() -> str:
    return datetime.now(timezone.utc).isoformat(timespec="microseconds")


def text(value: str, label: str, limit: int = 160, allow_empty: bool = False) -> str:
    if not isinstance(value, str):
        raise Invalid(f"{label} must be text")
    value = value.strip()
    if (not value and not allow_empty) or len(value) > limit or "\x00" in value:
        raise Invalid(f"{label} must contain {'0' if allow_empty else '1'}–{limit} characters")
    return value


def email(value: str) -> str:
    value = text(value, "Email", 254).lower()
    if not re.fullmatch(r"[^\s@]+@[^\s@]+\.[^\s@]+", value):
        raise Invalid("Enter a valid email address")
    return value


def color(value: str) -> str:
    if value not in COLORS:
        raise Invalid("Choose a supported color")
    return value


def member_role(value: str) -> str:
    if value not in (Role.MEMBER.value, Role.VIEWER.value):
        raise Invalid("Choose member or viewer; ownership cannot be reassigned")
    return value


def due_date(value: str | None) -> str | None:
    if value is None or value == "":
        return None
    try:
        parsed = date.fromisoformat(value)
    except (ValueError, TypeError):
        raise Invalid("Due date must be YYYY-MM-DD") from None
    if parsed.isoformat() != value:
        raise Invalid("Due date must be YYYY-MM-DD")
    return value


def require_role(role: str | None, permission: str = "view") -> None:
    if role is None:
        raise NotFound("Resource not found")
    if permission == "owner" and role != Role.OWNER.value:
        raise Forbidden("Only the owner can manage members")
    if permission == "edit" and role == Role.VIEWER.value:
        raise Forbidden("Viewers have read-only access")


def check_version(row: dict, expected: int) -> None:
    if row["version"] != expected:
        raise Conflict("Changed since you opened it. Refresh and try again.")


def overdue(card: dict, is_done: bool, today: date | None = None) -> bool:
    return bool(card.get("due_date") and not is_done and
                date.fromisoformat(str(card["due_date"])) < (today or datetime.now(timezone.utc).date()))