rahulpawar/taskdeck
Lanebook, a team task board (boards, lists, cards, roles, history) you run from the web or from Claude/ChatGPT over MCP. FastAPI + DataK3 + appid + Models.
A remix template · 0 remixes · Remix rahulpawar/taskdeck
app/boards/application/use_cases.py
"""Every action a person can take. The web routes and the MCP tools both call these and nothing
else, so a rule cannot be skipped by using the other door.
People talk about cards the way they see them: "card 12", "the Ops board", "Doing". The
resolvers below turn those words into ids, always inside the boards the person can see.
"""
import math
import re
import uuid
from datetime import date, datetime, timedelta, timezone
from typing import Callable, Optional
from boards.application.ports import Assistant, BoardRepository
from boards.domain.model import (DEFAULT_LISTS, Board, BoardList, Card, Forbidden, Invalid,
Person, Role, clean_labels, clean_title, describe_changes)
class NotFound(Exception):
pass
def _today() -> date:
return datetime.now(timezone.utc).date()
def parse_due(value, today: date) -> Optional[date]:
"""ISO dates plus the words people (and assistants) actually use."""
if value is None:
return None
if isinstance(value, date):
return value
v = str(value).strip().lower()
if v in ("", "none", "null", "no date", "clear"):
return None
if v == "today":
return today
if v == "tomorrow":
return today + timedelta(days=1)
m = re.fullmatch(r"in (\d+) days?", v)
if m:
return today + timedelta(days=int(m.group(1)))
days = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]
if v.replace("next ", "") in days:
ahead = (days.index(v.replace("next ", "")) - today.weekday()) % 7 or 7
return today + timedelta(days=ahead)
try:
return date.fromisoformat(v[:10])
except ValueError:
raise Invalid(f"due date {value!r}: use YYYY-MM-DD, 'today', 'tomorrow' or a weekday")
class Boards:
def __init__(self, repo: BoardRepository, assistant: Optional[Assistant] = None,
today: Callable[[], date] = _today):
self.repo, self.assistant, self.today = repo, assistant, today
# ---------- resolving words into things the person can see ----------
def _board(self, board_id: str, person: Person) -> Board:
board = self.repo.get(board_id) if board_id else None
if board is None or board.role_of(person) is None:
raise NotFound("no such board") # a board you can't see doesn't exist for you
return board
def _visible(self, person: Person) -> list:
return self.repo.boards_for(person.email)
def resolve_board(self, person: Person, ref: str) -> Board:
"""A board id or its title (case-insensitive), among the boards you are on."""
ref = (ref or "").strip()
if not ref:
raise Invalid("which board? (id or title)")
boards = self._visible(person)
for b in boards:
if b.id == ref:
return b
key = ref.lower().removesuffix(" board").strip()
exact = [b for b in boards if b.title.lower() == key]
if len(exact) == 1:
return exact[0]
partial = [b for b in boards if key in b.title.lower()]
if len(partial) == 1:
return partial[0]
if not exact and not partial:
raise NotFound(f"no board called {ref!r} among yours")
names = ", ".join(sorted(b.title for b in (exact or partial)))
raise Invalid(f"{ref!r} matches several boards ({names}); say which")
def _lists(self, board_id: str) -> list:
return sorted(self.repo.lists_on(board_id), key=lambda l: (l.position, l.name))
def _done_list_id(self, lists: list) -> Optional[str]:
"""The board's last list is its 'done' list."""
return lists[-1].id if lists else None
def resolve_list(self, board: Board, ref: str) -> BoardList:
lists = self._lists(board.id)
ref = (ref or "").strip()
for l in lists:
if l.id == ref or l.name.lower() == ref.lower():
return l
names = ", ".join(l.name for l in lists)
raise Invalid(f"board {board.title!r} has no list {ref!r}; its lists are {names}")
def resolve_card(self, person: Person, ref, board_ref: Optional[str] = None) -> tuple:
"""'12', '#12', 'card 12' (per-board numbers) or a card id."""
s = str(ref).strip().lower().removeprefix("card").strip().lstrip("#")
boards = [self.resolve_board(person, board_ref)] if board_ref else self._visible(person)
by_id = {b.id: b for b in boards}
if s.isdigit():
hits = [c for c in self.repo.cards_on(list(by_id)) if c.number == int(s)]
if not hits:
raise NotFound(f"no card {int(s)} on your boards")
if len(hits) > 1:
names = ", ".join(sorted(by_id[c.board_id].title for c in hits))
raise Invalid(f"card {int(s)} exists on several boards ({names}); say which board")
return by_id[hits[0].board_id], hits[0]
card = self.repo.get_card(str(ref).strip())
if card is None or card.board_id not in by_id:
raise NotFound("no such card")
return by_id[card.board_id], card
# ---------- presenting ----------
def _card_view(self, c: Card, lists: dict, done_id: Optional[str], numbers: dict) -> dict:
today = self.today()
return {"id": c.id, "number": c.number, "board_id": c.board_id, "title": c.title,
"description": c.description, "list_id": c.list_id,
"list": lists[c.list_id].name if c.list_id in lists else None,
"assignee": c.assignee, "due_date": c.due_date.isoformat() if c.due_date else None,
"labels": c.labels, "position": c.position,
"blocked_by": [{"id": b, "number": numbers.get(b)} for b in c.blocked_by],
"done": c.is_done(done_id), "overdue": c.is_overdue(today, done_id)}
def _board_cards(self, board: Board) -> tuple:
lists = self._lists(board.id)
cards = self.repo.cards_on([board.id])
return lists, cards
# ---------- boards and people ----------
def create_board(self, person: Person, title: str) -> dict:
board = Board(id=uuid.uuid4().hex[:12], title=clean_title(title, "a board"),
owner_email=person.email)
self.repo.save_board(board)
for i, name in enumerate(DEFAULT_LISTS):
self.repo.save_list(BoardList(uuid.uuid4().hex[:12], board.id, name, float(i + 1)))
self.repo.log(board.id, None, person.email, f"created board {board.title!r}")
return {"id": board.id, "title": board.title, "lists": list(DEFAULT_LISTS)}
def my_boards(self, person: Person) -> list:
return [{"id": b.id, "title": b.title, "role": b.role_of(person).value,
"owner": b.owner_email, "people": len(b.people())}
for b in sorted(self._visible(person), key=lambda b: b.title.lower())]
def show_board(self, person: Person, board_ref: str) -> dict:
board = self.resolve_board(person, board_ref)
lists, cards = self._board_cards(board)
by_id = {l.id: l for l in lists}
done_id = self._done_list_id(lists)
numbers = {c.id: c.number for c in cards}
views = [self._card_view(c, by_id, done_id, numbers)
for c in sorted(cards, key=lambda c: (c.position, c.number))]
return {"id": board.id, "title": board.title, "role": board.role_of(person).value,
"owner": board.owner_email,
"people": [{"email": e, "role": r.value} for e, r in board.people().items()],
"lists": [{"id": l.id, "name": l.name, "position": l.position,
"cards": [v for v in views if v["list_id"] == l.id]} for l in lists],
"overdue": sum(1 for v in views if v["overdue"])}
def rename_board(self, person: Person, board_ref: str, title: str) -> dict:
board = self.resolve_board(person, board_ref)
board.require_owner(person)
before, board.title = board.title, clean_title(title, "a board")
self.repo.save_board(board)
self.repo.log(board.id, None, person.email, f"renamed board {before!r} to {board.title!r}")
return {"id": board.id, "title": board.title}
def add_member(self, person: Person, board_ref: str, email: str, role: str = "member") -> dict:
board = self.resolve_board(person, board_ref)
try:
r = Role(str(role).strip().lower())
except ValueError:
raise Invalid("role is 'member' or 'viewer'")
existed = board.members.get(email.strip().lower())
email = board.add_member(person, email, r)
self.repo.save_board(board)
what = f"changed {email} from {existed.value} to {r.value}" if existed else \
f"invited {email} as {r.value}"
self.repo.log(board.id, None, person.email, what)
return {"board_id": board.id, "email": email, "role": r.value}
def remove_member(self, person: Person, board_ref: str, email: str) -> dict:
board = self.resolve_board(person, board_ref)
email = board.remove_member(person, email)
self.repo.remove_membership(board.id, email)
self.repo.log(board.id, None, person.email, f"removed {email}")
return {"board_id": board.id, "removed": email}
# ---------- lists ----------
def add_list(self, person: Person, board_ref: str, name: str) -> dict:
board = self.resolve_board(person, board_ref)
board.require_edit(person)
name = clean_title(name, "a list")
lists = self._lists(board.id)
if any(l.name.lower() == name.lower() for l in lists):
raise Invalid(f"there is already a list {name!r}")
# new lists go just before the last ('done') list, so 'done' stays last
if len(lists) >= 2:
pos = (lists[-2].position + lists[-1].position) / 2
else:
pos = (lists[-1].position + 1) if lists else 1.0
lst = BoardList(uuid.uuid4().hex[:12], board.id, name, pos)
self.repo.save_list(lst)
self.repo.log(board.id, None, person.email, f"added list {name!r}")
return {"id": lst.id, "name": lst.name, "position": lst.position}
def rename_list(self, person: Person, board_ref: str, list_ref: str, name: str) -> dict:
board = self.resolve_board(person, board_ref)
board.require_edit(person)
lst = self.resolve_list(board, list_ref)
before, lst.name = lst.name, clean_title(name, "a list")
self.repo.save_list(lst)
self.repo.log(board.id, None, person.email, f"renamed list {before!r} to {lst.name!r}")
return {"id": lst.id, "name": lst.name}
def delete_list(self, person: Person, board_ref: str, list_ref: str) -> dict:
board = self.resolve_board(person, board_ref)
board.require_edit(person)
lst = self.resolve_list(board, list_ref)
if any(c.list_id == lst.id for c in self.repo.cards_on([board.id])):
raise Invalid(f"list {lst.name!r} still has cards; move them first")
if len(self._lists(board.id)) <= 2:
raise Invalid("a board keeps at least two lists")
self.repo.delete_list(lst.id)
self.repo.log(board.id, None, person.email, f"deleted list {lst.name!r}")
return {"deleted": lst.name}
# ---------- cards ----------
def add_card(self, person: Person, board_ref: str, title: str, list_ref: str = "",
description: str = "", assignee: Optional[str] = None, due_date=None,
labels=None) -> dict:
board = self.resolve_board(person, board_ref)
board.require_edit(person)
lists = self._lists(board.id)
lst = self.resolve_list(board, list_ref) if list_ref else lists[0]
if assignee == "me":
assignee = person.email
siblings = [c.position for c in self.repo.cards_on([board.id]) if c.list_id == lst.id]
card = Card(id=uuid.uuid4().hex[:12], board_id=board.id,
number=self.repo.next_card_number(board.id), list_id=lst.id,
title=clean_title(title), description=(description or "").strip()[:5000],
assignee=board.check_assignee(assignee),
due_date=parse_due(due_date, self.today()), labels=clean_labels(labels),
position=(max(siblings) + 1) if siblings else 1.0)
self.repo.save_card(card)
extra = []
if card.assignee:
extra.append(f"assigned to {card.assignee}")
if card.due_date:
extra.append(f"due {card.due_date.isoformat()}")
self.repo.log(board.id, card.id, person.email,
f"added card #{card.number} {card.title!r} to {lst.name}"
+ (f" ({', '.join(extra)})" if extra else ""))
return self._one(board, card)
def _one(self, board: Board, card: Card) -> dict:
lists, cards = self._board_cards(board)
return self._card_view(card, {l.id: l for l in lists}, self._done_list_id(lists),
{c.id: c.number for c in cards})
def _save_changes(self, person: Person, board: Board, card: Card, before: dict) -> dict:
lists, cards = self._board_cards(board)
lines = describe_changes(before, card.snapshot(), {l.id: l.name for l in lists},
{c.id: c.number for c in cards})
self.repo.save_card(card)
for line in lines:
self.repo.log(board.id, card.id, person.email, f"card #{card.number}: {line}")
out = self._one(board, card)
out["changes"] = lines
return out
def show_card(self, person: Person, card_ref, board_ref: Optional[str] = None) -> dict:
board, card = self.resolve_card(person, card_ref, board_ref)
out = self._one(board, card)
out["board"] = board.title
out["history"] = self.repo.history(board.id, card.id, 50)
return out
def update_card(self, person: Person, card_ref, board_ref: Optional[str] = None,
title: Optional[str] = None, description: Optional[str] = None,
assignee: Optional[str] = None, due_date=None, labels=None,
clear: tuple = ()) -> dict:
"""Change any of the fields given; `clear` names fields to empty (assignee, due_date,
labels, description)."""
board, card = self.resolve_card(person, card_ref, board_ref)
board.require_edit(person)
before = card.snapshot()
if title is not None:
card.title = clean_title(title)
if description is not None:
card.description = description.strip()[:5000]
if assignee is not None:
card.assignee = board.check_assignee(person.email if assignee == "me" else assignee)
if due_date is not None:
card.due_date = parse_due(due_date, self.today())
if labels is not None:
card.labels = clean_labels(labels)
for f in clear:
if f == "assignee":
card.assignee = None
elif f == "due_date":
card.due_date = None
elif f == "labels":
card.labels = []
elif f == "description":
card.description = ""
elif f == "blocked_by":
card.blocked_by = []
else:
raise Invalid(f"can't clear {f!r}")
return self._save_changes(person, board, card, before)
def assign_card(self, person: Person, card_ref, assignee: Optional[str],
board_ref: Optional[str] = None) -> dict:
if not assignee or str(assignee).lower() in ("none", "nobody", "unassign"):
return self.update_card(person, card_ref, board_ref, clear=("assignee",))
return self.update_card(person, card_ref, board_ref, assignee=assignee)
def move_card(self, person: Person, card_ref, list_ref: str, board_ref: Optional[str] = None,
position: Optional[float] = None) -> dict:
board, card = self.resolve_card(person, card_ref, board_ref)
board.require_edit(person)
lst = self.resolve_list(board, list_ref)
before = card.snapshot()
if position is None or not math.isfinite(float(position)):
siblings = [c.position for c in self.repo.cards_on([board.id])
if c.list_id == lst.id and c.id != card.id]
position = (max(siblings) + 1) if siblings else 1.0
card.list_id, card.position = lst.id, float(position)
if before["list_id"] == lst.id: # a reorder inside the list
self.repo.save_card(card)
self.repo.log(board.id, card.id, person.email,
f"card #{card.number}: reordered within {lst.name}")
return self._one(board, card)
return self._save_changes(person, board, card, before)
def delete_card(self, person: Person, card_ref, board_ref: Optional[str] = None) -> dict:
board, card = self.resolve_card(person, card_ref, board_ref)
board.require_edit(person)
for other in self.repo.cards_on([board.id]):
if card.id in other.blocked_by:
other.blocked_by = [x for x in other.blocked_by if x != card.id]
self.repo.save_card(other)
self.repo.delete_card(card.id)
self.repo.log(board.id, card.id, person.email, f"deleted card #{card.number} {card.title!r}")
return {"deleted": card.number, "title": card.title}
# ---------- dependencies ----------
def set_blocked_by(self, person: Person, card_ref, blockers: list,
board_ref: Optional[str] = None) -> dict:
board, card = self.resolve_card(person, card_ref, board_ref)
board.require_edit(person)
ids = []
for ref in blockers or []:
b2, other = self.resolve_card(person, ref, board.id)
if other.id == card.id:
raise Invalid("a card can't block itself")
ids.append(other.id)
by_id = {c.id: c for c in self.repo.cards_on([board.id])}
# no cycles: walk what each new blocker is (transitively) blocked by
stack, seen = list(ids), set()
while stack:
x = stack.pop()
if x == card.id:
raise Invalid("that would make a loop of blockers")
if x in seen or x not in by_id:
continue
seen.add(x)
stack.extend(by_id[x].blocked_by)
before = card.snapshot()
card.blocked_by = list(dict.fromkeys(ids))
return self._save_changes(person, board, card, before)
def what_is_blocking(self, person: Person, card_ref, board_ref: Optional[str] = None) -> dict:
"""Every unfinished card standing in the way, directly or further down the chain."""
board, card = self.resolve_card(person, card_ref, board_ref)
lists, cards = self._board_cards(board)
done_id = self._done_list_id(lists)
by_id = {c.id: c for c in cards}
names = {l.id: l.name for l in lists}
out, seen, stack = [], set(), [(b, 1) for b in card.blocked_by]
while stack:
cid, depth = stack.pop(0)
if cid in seen or cid not in by_id:
continue
seen.add(cid)
c = by_id[cid]
if not c.is_done(done_id):
out.append({"number": c.number, "title": c.title, "list": names.get(c.list_id),
"assignee": c.assignee, "depth": depth,
"due_date": c.due_date.isoformat() if c.due_date else None})
stack.extend((b, depth + 1) for b in c.blocked_by)
return {"card": card.number, "title": card.title, "blocked": bool(out), "blockers": out}
# ---------- questions people ask ----------
def _all_my_cards(self, person: Person) -> list:
"""(board, card, view) for every card on every board the person can see."""
out = []
boards = self._visible(person)
cards = self.repo.cards_on([b.id for b in boards])
for b in boards:
lists = self._lists(b.id)
by_id, done_id = {l.id: l for l in lists}, self._done_list_id(lists)
mine = [c for c in cards if c.board_id == b.id]
numbers = {c.id: c.number for c in mine}
for c in mine:
v = self._card_view(c, by_id, done_id, numbers)
v["board"] = b.title
out.append((b, c, v))
return out
def my_plate(self, person: Person, days: int = 7) -> dict:
"""'What's on my plate this week?': my unfinished cards that are overdue, due within
`days`, or in progress with no date."""
today = self.today()
until = today + timedelta(days=max(0, int(days)))
overdue, due, undated = [], [], []
for b, c, v in self._all_my_cards(person):
if c.assignee != person.email or v["done"]:
continue
if v["overdue"]:
overdue.append(v)
elif c.due_date and c.due_date <= until:
due.append(v)
elif not c.due_date:
undated.append(v)
key = lambda v: (v["due_date"] or "9999", v["board"], v["number"])
return {"today": today.isoformat(), "until": until.isoformat(),
"overdue": sorted(overdue, key=key), "due_soon": sorted(due, key=key),
"no_due_date": sorted(undated, key=key)}
def overdue(self, person: Person, board_ref: Optional[str] = None) -> list:
board_id = self.resolve_board(person, board_ref).id if board_ref else None
rows = [v for b, c, v in self._all_my_cards(person)
if v["overdue"] and (board_id is None or b.id == board_id)]
return sorted(rows, key=lambda v: (v["due_date"], v["board"], v["number"]))
def search_cards(self, person: Person, text: str, board_ref: Optional[str] = None) -> list:
words = [w for w in re.findall(r"\w+", (text or "").lower()) if len(w) > 1]
if not words:
raise Invalid("search for at least one word")
board_id = self.resolve_board(person, board_ref).id if board_ref else None
hits = []
for b, c, v in self._all_my_cards(person):
if board_id and b.id != board_id:
continue
hay = " ".join([c.title, c.description, " ".join(c.labels), c.assignee or ""]).lower()
score = sum(hay.count(w) for w in words)
if score:
hits.append((score, v))
return [v for s, v in sorted(hits, key=lambda x: -x[0])][:25]
def history(self, person: Person, board_ref: str, limit: int = 30) -> list:
board = self.resolve_board(person, board_ref)
return self.repo.history(board.id, None, max(1, min(int(limit), 200)))
# ---------- AI (Dodil Models) ----------
def _need_ai(self):
if self.assistant is None:
raise Invalid("no AI assistant configured")
return self.assistant
def suggest_checklist(self, person: Person, card_ref, apply: bool = False,
board_ref: Optional[str] = None) -> dict:
"""'Turn this card into a checklist'. Without apply it only suggests; with apply it
appends the steps to the card's description as [ ] items (logged like any edit)."""
board, card = self.resolve_card(person, card_ref, board_ref)
steps = self._need_ai().checklist(card.title, card.description)
if not apply:
return {"card": card.number, "suggested_checklist": steps, "applied": False}
desc = (card.description + "\n\n" if card.description else "") + \
"\n".join(f"[ ] {s}" for s in steps)
out = self.update_card(person, card.id, board.id, description=desc)
out["suggested_checklist"], out["applied"] = steps, True
return out
def board_summary(self, person: Person, board_ref: str) -> dict:
"""A short daily summary: what moved in the last day, what's overdue, what's next."""
b = self.show_board(person, board_ref)
lines = []
for l in b["lists"]:
lines.append(f"List {l['name']}: " + "; ".join(
f"#{c['number']} {c['title']}" + (f" ({c['assignee']})" if c["assignee"] else "")
+ (f" due {c['due_date']}" if c["due_date"] else "")
+ (" OVERDUE" if c["overdue"] else "") for c in l["cards"]))
recent = self.repo.history(b["id"], None, 25)
lines.append("Recent activity: " + "; ".join(f"{h['at'][:16]} {h['actor']} {h['action']}"
for h in recent))
text = self._need_ai().summary(b["title"], "\n".join(lines))
return {"board": b["title"], "today": self.today().isoformat(), "summary": text}
def similar_cards(self, person: Person, card_ref, board_ref: Optional[str] = None,
limit: int = 5) -> list:
"""'Find cards like this one': search by meaning (Dodil Models embeddings) across the
boards you can see."""
board, card = self.resolve_card(person, card_ref, board_ref)
rows = [(c, v) for b, c, v in self._all_my_cards(person) if c.id != card.id]
if not rows:
return []
text = lambda c: f"{c.title}. {' '.join(c.labels)}. {c.description}".strip()
texts = [text(card)] + [text(c) for c, v in rows]
vecs = self._need_ai().embed(texts)
def cos(a, b):
na = math.sqrt(sum(x * x for x in a)) or 1.0
nb = math.sqrt(sum(x * x for x in b)) or 1.0
return sum(x * y for x, y in zip(a, b)) / (na * nb)
scored = sorted(((cos(vecs[0], vecs[i + 1]), v) for i, (c, v) in enumerate(rows)),
key=lambda x: -x[0])
return [{**v, "similarity": round(s, 3)} for s, v in scored[:max(1, min(limit, 20))]]