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 => ({
        "&": "&amp;",
        "<": "&lt;",
        ">": "&gt;",
        '"': "&quot;",
        "'": "&#39;"
    })[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>
"""