freelancer/flowboard
Boards starter for the Dodil Boards Challenge. FastAPI + DataK3 + appid + Models + MCP.
A remix template · 0 remixes · Remix freelancer/flowboard
app/boards/doors/web.py
"""The web door: JSON routes plus one small page.
Every route calls a use case and nothing else.
"""
from typing import Optional
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from pydantic import BaseModel
from boards.application.use_cases import Boards, NotFound
from boards.doors.identity import person_from
from boards.domain.model import Forbidden, Invalid, Person
def build_router(boards: Boards) -> APIRouter:
r = APIRouter()
def me(request: Request) -> Person:
person = person_from(request.headers)
if person is None:
raise HTTPException(
status_code=401,
detail="sign in first: /.dodil/auth/login",
)
return person
def run(fn, *args, **kwargs):
try:
return fn(*args, **kwargs)
except NotFound as exc:
raise HTTPException(
status_code=404,
detail=str(exc),
) from exc
except Forbidden as exc:
raise HTTPException(
status_code=403,
detail=str(exc),
) from exc
except Invalid as exc:
raise HTTPException(
status_code=422,
detail=str(exc),
) from exc
class Title(BaseModel):
title: str
class CardIn(BaseModel):
title: str
list_name: str = "To do"
class Move(BaseModel):
list_name: str
class CardUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
assignee: Optional[str] = None
due_date: Optional[str] = None
labels: Optional[list[str]] = None
class Member(BaseModel):
email: str
role: str = "member"
@r.get("/api/boards")
def my_boards(request: Request):
return run(boards.my_boards, me(request))
@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.get("/api/boards/{board_id}/activity")
def activity_history(
board_id: str,
request: Request,
limit: int = Query(default=100, ge=1, le=500),
):
return run(
boards.activity_history,
me(request),
board_id,
limit,
)
@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.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.title,
body.list_name,
)
@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,
)
@r.patch("/api/cards/{card_id}")
def update_card(
card_id: str,
body: CardUpdate,
request: Request,
):
return run(
boards.update_card,
me(request),
card_id,
**body.model_dump(exclude_unset=True),
)
@r.post("/api/cards/{card_id}/checklist")
def suggest_checklist(card_id: str, request: Request):
return run(
boards.suggest_checklist,
me(request),
card_id,
)
@r.get("/", response_class=HTMLResponse)
def page(request: Request):
if person_from(request.headers) is None:
return RedirectResponse(
"/.dodil/auth/login?return_to=/",
status_code=303,
)
return PAGE
return r
PAGE = """<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Flowboard</title>
<style>
body {
font: 15px system-ui, sans-serif;
margin: 2rem auto;
padding: 0 1rem;
max-width: 64rem;
color: #172033;
}
.cols {
display: flex;
gap: 1rem;
align-items: flex-start;
}
.col {
flex: 1;
min-width: 0;
background: #f3f4f6;
border-radius: 8px;
padding: .6rem;
min-height: 6rem;
}
.card {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 6px;
padding: .6rem;
margin: .5rem 0;
overflow-wrap: anywhere;
}
.card small {
display: block;
margin-top: .5rem;
}
a { color: #3156b8; }
input, button {
font: inherit;
padding: .45rem .6rem;
margin: .2rem 0;
}
#board { margin-top: 1.5rem; }
#error { color: #b42318; }
.activity {
border-top: 1px solid #e5e7eb;
padding: .5rem 0;
}
@media (max-width: 600px) {
.cols { flex-direction: column; }
.col { width: 100%; box-sizing: border-box; }
}
</style>
<h1>Flowboard</h1>
<p>
Signed in through Dodil appid.
<a href="/.dodil/auth/logout">Sign out</a>
</p>
<p id="error" role="alert"></p>
<form id="nb">
<input id="t" placeholder="New board title" required>
<button type="submit">Create board</button>
</form>
<h2>Your boards</h2>
<ul id="list"></ul>
<div id="board"></div>
<script>
const j = async (url, options = {}) => {
const response = await fetch(url, {
headers: {"content-type": "application/json"},
...options
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data.detail || `Request failed (${response.status})`);
}
return data;
};
const errorBox = document.getElementById("error");
const boardBox = document.getElementById("board");
const boardList = document.getElementById("list");
const titleInput = document.getElementById("t");
function escapeHtml(value) {
return String(value ?? "").replace(/[&<>"']/g, char => ({
"&": "&",
"<": "<",
">": ">",
'"': """,
"'": "'"
})[char]);
}
function showError(error) {
errorBox.textContent = error.message || String(error);
}
async function load() {
try {
const boards = await j("/api/boards");
boardList.innerHTML = boards.map(item => `
<li>
<a href="#" data-board-id="${escapeHtml(item.id)}">
${escapeHtml(item.title)}
</a>
(${escapeHtml(item.role)})
</li>
`).join("");
} catch (error) {
showError(error);
}
}
boardList.addEventListener("click", event => {
const link = event.target.closest("[data-board-id]");
if (!link) return;
event.preventDefault();
show(link.dataset.boardId);
});
async function show(id) {
try {
errorBox.textContent = "";
const data = await j(
"/api/boards/" + encodeURIComponent(id)
);
boardBox.innerHTML = `
<h2>${escapeHtml(data.title)}</h2>
<p>Role: ${escapeHtml(data.role)}</p>
<form id="add-card-form" data-board-id="${escapeHtml(id)}">
<input name="cardTitle"
placeholder="New card title"
required>
<button type="submit">Add card</button>
</form>
<div class="cols">
${["To do", "Doing", "Done"].map(listName => `
<div class="col">
<b>${escapeHtml(listName)}</b>
${data.cards
.filter(card => card.list_name === listName)
.map(card => `
<div class="card">
<strong>${escapeHtml(card.title)}</strong>
${card.description
? `<p>${escapeHtml(card.description)}</p>`
: ""}
${card.assignee
? `<small>Assignee: ${escapeHtml(card.assignee)}</small>`
: ""}
${card.due_date
? `<small>Due: ${escapeHtml(card.due_date)}</small>`
: ""}
${card.labels?.length
? `<small>Labels: ${card.labels.map(escapeHtml).join(", ")}</small>`
: ""}
<small>
${["To do", "Doing", "Done"]
.filter(other => other !== listName)
.map(other => `
<a href="#"
data-move-card="${escapeHtml(card.id)}"
data-list="${escapeHtml(other)}"
data-board="${escapeHtml(id)}">
→ ${escapeHtml(other)}
</a>
`).join(" ")}
</small>
<details>
<summary>Edit card</summary>
<form data-edit-card="${escapeHtml(card.id)}"
data-board="${escapeHtml(id)}">
<p>
<input name="title"
value="${escapeHtml(card.title)}"
placeholder="Title"
required>
</p>
<p>
<input name="description"
value="${escapeHtml(card.description || "")}"
placeholder="Description">
</p>
<p>
<input name="assignee"
value="${escapeHtml(card.assignee || "")}"
placeholder="Assignee">
</p>
<p>
<input name="due_date"
type="date"
value="${escapeHtml(card.due_date || "")}">
</p>
<p>
<input name="labels"
value="${escapeHtml((card.labels || []).join(", "))}"
placeholder="Labels, comma-separated">
</p>
<button type="submit">Save changes</button>
</form>
</details>
</div>
`).join("")}
</div>
`).join("")}
</div>
<section id="activity">
<h3>Recent activity</h3>
<button type="button" id="load-activity"
data-board="${escapeHtml(id)}">
Load activity history
</button>
<div id="activity-items"></div>
</section>
`;
} catch (error) {
showError(error);
}
}
boardBox.addEventListener("submit", async event => {
const addForm = event.target.closest("#add-card-form");
const editForm = event.target.closest("[data-edit-card]");
if (!addForm && !editForm) return;
event.preventDefault();
try {
errorBox.textContent = "";
if (addForm) {
const formData = new FormData(addForm);
await j(
`/api/boards/${encodeURIComponent(addForm.dataset.boardId)}/cards`,
{
method: "POST",
body: JSON.stringify({
title: String(formData.get("cardTitle") || "")
})
}
);
await show(addForm.dataset.boardId);
return;
}
if (editForm) {
const formData = new FormData(editForm);
const labelsText = String(formData.get("labels") || "");
const payload = {
title: String(formData.get("title") || ""),
description: String(formData.get("description") || ""),
assignee: String(formData.get("assignee") || ""),
due_date: String(formData.get("due_date") || ""),
labels: labelsText
.split(",")
.map(label => label.trim())
.filter(Boolean)
};
await j(
`/api/cards/${encodeURIComponent(editForm.dataset.editCard)}`,
{
method: "PATCH",
body: JSON.stringify(payload)
}
);
await show(editForm.dataset.board);
}
} catch (error) {
showError(error);
}
});
boardBox.addEventListener("click", async event => {
const moveLink = event.target.closest("[data-move-card]");
const activityButton = event.target.closest("#load-activity");
if (moveLink) {
event.preventDefault();
try {
errorBox.textContent = "";
await j(
`/api/cards/${encodeURIComponent(moveLink.dataset.moveCard)}/move`,
{
method: "POST",
body: JSON.stringify({
list_name: moveLink.dataset.list
})
}
);
await show(moveLink.dataset.board);
} catch (error) {
showError(error);
}
return;
}
if (activityButton) {
const boardId = activityButton.dataset.board;
const activityItems = document.getElementById("activity-items");
try {
errorBox.textContent = "";
const result = await j(
`/api/boards/${encodeURIComponent(boardId)}/activity?limit=100`
);
activityItems.innerHTML = result.activity.length
? result.activity.map(item => `
<div class="activity">
<strong>${escapeHtml(item.actor)}</strong>:
${escapeHtml(item.action)}
<br>
<small>${escapeHtml(item.event_at || "")}</small>
</div>
`).join("")
: "<p>No activity recorded yet.</p>";
} catch (error) {
showError(error);
}
}
});
document.getElementById("nb").addEventListener("submit", async event => {
event.preventDefault();
try {
errorBox.textContent = "";
await j("/api/boards", {
method: "POST",
body: JSON.stringify({title: titleInput.value})
});
titleInput.value = "";
await load();
} catch (error) {
showError(error);
}
});
load();
</script>
"""