518 lines
14 KiB
TypeScript
518 lines
14 KiB
TypeScript
import type {
|
|
Board,
|
|
Card,
|
|
CardFile,
|
|
CardHistoryResponse,
|
|
CardMessage,
|
|
Column,
|
|
KanbanModule,
|
|
Metrics,
|
|
MetricsFilter,
|
|
ModuleLog,
|
|
ModuleTestResult,
|
|
Notification,
|
|
Sticker,
|
|
User,
|
|
} from "./types";
|
|
import { fetchJSON as registryFetchJSON, HTTPError } from "@fn_library/infra/fetch_json";
|
|
|
|
export { HTTPError };
|
|
|
|
const BASE = "/api";
|
|
|
|
function fetchJSON<T>(path: string, init?: RequestInit): Promise<T> {
|
|
return registryFetchJSON<T>(path, init, BASE);
|
|
}
|
|
|
|
export function getBoard(): Promise<Board> {
|
|
return fetchJSON("/board");
|
|
}
|
|
|
|
export function getFlags(): Promise<Record<string, boolean>> {
|
|
return fetchJSON("/flags");
|
|
}
|
|
|
|
export function getVersion(): Promise<{ version: string }> {
|
|
return fetchJSON("/version");
|
|
}
|
|
|
|
export function createColumn(name: string): Promise<Column> {
|
|
return fetchJSON("/columns", { method: "POST", body: JSON.stringify({ name }) });
|
|
}
|
|
|
|
export interface UpdateColumnInput {
|
|
name?: string;
|
|
position?: number;
|
|
location?: "board" | "sidebar";
|
|
width?: number;
|
|
wip_limit?: number;
|
|
is_done?: boolean;
|
|
max_time_minutes?: number;
|
|
}
|
|
|
|
export function updateColumn(id: string, patch: UpdateColumnInput): Promise<void> {
|
|
return fetchJSON(`/columns/${id}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(patch),
|
|
});
|
|
}
|
|
|
|
export function deleteColumn(id: string): Promise<void> {
|
|
return fetchJSON(`/columns/${id}`, { method: "DELETE" });
|
|
}
|
|
|
|
export function reorderColumns(ids: string[]): Promise<void> {
|
|
return fetchJSON("/columns/reorder", { method: "POST", body: JSON.stringify({ ids }) });
|
|
}
|
|
|
|
export interface CreateCardInput {
|
|
column_id: string;
|
|
requester?: string;
|
|
title: string;
|
|
description?: string;
|
|
assignee_id?: string | null;
|
|
tags?: string[];
|
|
}
|
|
|
|
export function createCard(input: CreateCardInput): Promise<Card> {
|
|
return fetchJSON("/cards", { method: "POST", body: JSON.stringify(input) });
|
|
}
|
|
|
|
export interface UpdateCardInput {
|
|
requester?: string;
|
|
title?: string;
|
|
description?: string;
|
|
color?: string;
|
|
locked?: boolean;
|
|
assignee_id?: string | null;
|
|
tags?: string[];
|
|
deadline?: string | null;
|
|
}
|
|
|
|
export function updateCard(id: string, patch: UpdateCardInput): Promise<void> {
|
|
return fetchJSON(`/cards/${id}`, { method: "PATCH", body: JSON.stringify(patch) });
|
|
}
|
|
|
|
export function deleteCard(id: string): Promise<void> {
|
|
return fetchJSON(`/cards/${id}`, { method: "DELETE" });
|
|
}
|
|
|
|
export function updateCardStickers(id: string, stickers: Sticker[]): Promise<void> {
|
|
return fetchJSON(`/cards/${id}/stickers`, {
|
|
method: "PUT",
|
|
body: JSON.stringify({ stickers }),
|
|
});
|
|
}
|
|
|
|
export function listTrash(): Promise<Card[]> {
|
|
return fetchJSON("/trash");
|
|
}
|
|
|
|
export function restoreCard(id: string): Promise<void> {
|
|
return fetchJSON(`/cards/${id}/restore`, { method: "POST" });
|
|
}
|
|
|
|
export function purgeCard(id: string): Promise<void> {
|
|
return fetchJSON(`/cards/${id}/purge`, { method: "DELETE" });
|
|
}
|
|
|
|
export function listArchive(): Promise<Card[]> {
|
|
return fetchJSON("/archive");
|
|
}
|
|
|
|
export function archiveCard(id: string): Promise<void> {
|
|
return fetchJSON(`/cards/${id}/archive`, { method: "POST" });
|
|
}
|
|
|
|
export function unarchiveCard(id: string): Promise<void> {
|
|
return fetchJSON(`/cards/${id}/unarchive`, { method: "POST" });
|
|
}
|
|
|
|
export interface DailyReport {
|
|
date: string;
|
|
tz: string;
|
|
start_ts: string;
|
|
end_ts: string;
|
|
kpis: {
|
|
done: number;
|
|
created: number;
|
|
moves: number;
|
|
blocked_ms: number;
|
|
deadlines_met: number;
|
|
deadlines_missed: number;
|
|
reopened: number;
|
|
archived_auto: number;
|
|
archived_manual: number;
|
|
};
|
|
top_assignees_done: { user_id: string; name: string; count: number }[];
|
|
top_assignees_created: { user_id: string; name: string; count: number }[];
|
|
top_requesters_added: { name: string; count: number }[];
|
|
top_requesters_done: { name: string; count: number }[];
|
|
done_cards: {
|
|
id: string;
|
|
seq_num: number;
|
|
title: string;
|
|
requester: string;
|
|
assignee_id: string | null;
|
|
assignee_name: string | null;
|
|
tags: string[];
|
|
column_id: string;
|
|
column_name: string;
|
|
completed_at: string;
|
|
created_at: string;
|
|
lead_time_ms: number;
|
|
color: string;
|
|
}[];
|
|
reopened_cards: {
|
|
card_id: string;
|
|
title: string;
|
|
seq_num: number;
|
|
from_column: string;
|
|
to_column: string;
|
|
ts: string;
|
|
actor_id: string | null;
|
|
actor_name: string | null;
|
|
}[];
|
|
stale_cards: {
|
|
d7: StaleEntry[];
|
|
d14: StaleEntry[];
|
|
d30: StaleEntry[];
|
|
};
|
|
lead_time: { avg_ms: number; p50_ms: number; p95_ms: number; samples: number };
|
|
hourly_moves: number[];
|
|
deadlines: {
|
|
met: number;
|
|
missed: number;
|
|
list: {
|
|
card_id: string;
|
|
title: string;
|
|
seq_num: number;
|
|
deadline: string;
|
|
completed_at: string;
|
|
late_ms: number;
|
|
}[];
|
|
};
|
|
tags_done: { name: string; count: number }[];
|
|
archived_today: number;
|
|
}
|
|
|
|
export interface StaleEntry {
|
|
card_id: string;
|
|
title: string;
|
|
seq_num: number;
|
|
column_id: string;
|
|
column_name: string;
|
|
entered_at: string;
|
|
days: number;
|
|
}
|
|
|
|
export function dailyReport(date: string, tz?: string): Promise<DailyReport> {
|
|
const params = new URLSearchParams({ date });
|
|
if (tz) params.set("tz", tz);
|
|
return fetchJSON(`/reports/daily?${params.toString()}`);
|
|
}
|
|
|
|
export interface DailySummary {
|
|
date: string;
|
|
summary: string;
|
|
prompt?: string;
|
|
model?: string;
|
|
generated_at?: string;
|
|
generated_by?: string | null;
|
|
exists: boolean;
|
|
}
|
|
|
|
export function getDailySummary(date: string): Promise<DailySummary> {
|
|
return fetchJSON(`/reports/daily/summary?date=${encodeURIComponent(date)}`);
|
|
}
|
|
|
|
export function generateDailySummary(date: string, tz?: string): Promise<DailySummary> {
|
|
const params = new URLSearchParams({ date });
|
|
if (tz) params.set("tz", tz);
|
|
return fetchJSON(`/reports/daily/summary?${params.toString()}`, { method: "POST" });
|
|
}
|
|
|
|
export function getSetting(key: string): Promise<{ key: string; value: string }> {
|
|
return fetchJSON(`/settings/${encodeURIComponent(key)}`);
|
|
}
|
|
|
|
export function setSetting(key: string, value: string): Promise<void> {
|
|
return fetchJSON(`/settings/${encodeURIComponent(key)}`, {
|
|
method: "PUT",
|
|
body: JSON.stringify({ value }),
|
|
});
|
|
}
|
|
|
|
export function moveCard(id: string, column_id: string, ordered_ids: string[]): Promise<void> {
|
|
return fetchJSON(`/cards/${id}/move`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ column_id, ordered_ids }),
|
|
});
|
|
}
|
|
|
|
export function cardHistory(id: string): Promise<CardHistoryResponse> {
|
|
return fetchJSON(`/cards/${id}/history`);
|
|
}
|
|
|
|
export function listCardMessages(id: string): Promise<CardMessage[]> {
|
|
return fetchJSON(`/cards/${id}/messages`);
|
|
}
|
|
|
|
export function createCardMessage(id: string, body: string): Promise<CardMessage> {
|
|
return fetchJSON(`/cards/${id}/messages`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ body }),
|
|
});
|
|
}
|
|
|
|
export function deleteCardMessage(cardId: string, messageId: string): Promise<void> {
|
|
return fetchJSON(`/cards/${cardId}/messages/${messageId}`, { method: "DELETE" });
|
|
}
|
|
|
|
export function duplicateCard(id: string): Promise<Card> {
|
|
return fetchJSON(`/cards/${id}/duplicate`, { method: "POST" });
|
|
}
|
|
|
|
export interface ChatMessage {
|
|
role: "user" | "assistant";
|
|
content: string;
|
|
}
|
|
|
|
export interface ChatToolCall {
|
|
tool: string;
|
|
ok: boolean;
|
|
error?: string;
|
|
input?: unknown;
|
|
}
|
|
|
|
// WebSocket streaming events emitted by /api/chat/ws.
|
|
export type ChatStreamEvent =
|
|
| { type: "delta"; text: string }
|
|
| { type: "tool_use"; tool_id: string; tool: string; input?: unknown }
|
|
| { type: "tool_result"; tool_id: string; result?: string; is_error?: boolean }
|
|
| { type: "result"; text?: string; is_error?: boolean }
|
|
| { type: "done"; board_changed?: boolean }
|
|
| { type: "error"; error: string };
|
|
|
|
// chatWSURL builds the absolute ws:// or wss:// URL of the streaming endpoint.
|
|
export function chatWSURL(): string {
|
|
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
return `${proto}//${window.location.host}/api/chat/ws`;
|
|
}
|
|
|
|
export function cardChatWSURL(cardId: string): string {
|
|
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
return `${proto}//${window.location.host}/api/cards/${cardId}/chat/ws`;
|
|
}
|
|
|
|
export function listNotifications(unreadOnly = false): Promise<Notification[]> {
|
|
return fetchJSON(`/notifications${unreadOnly ? "?unread=1" : ""}`);
|
|
}
|
|
|
|
export function unreadNotificationCount(): Promise<{ count: number }> {
|
|
return fetchJSON("/notifications/unread-count");
|
|
}
|
|
|
|
export function markNotificationRead(id: string): Promise<void> {
|
|
return fetchJSON(`/notifications/${id}/read`, { method: "POST" });
|
|
}
|
|
|
|
export function markAllNotificationsRead(): Promise<{ count: number }> {
|
|
return fetchJSON("/notifications/read-all", { method: "POST" });
|
|
}
|
|
|
|
export function listModules(): Promise<KanbanModule[]> {
|
|
return fetchJSON("/modules");
|
|
}
|
|
|
|
export interface ModuleInput {
|
|
name: string;
|
|
kind: string;
|
|
enabled: boolean;
|
|
event_filter: string[];
|
|
config: Record<string, unknown>;
|
|
}
|
|
|
|
export function createModule(body: ModuleInput): Promise<KanbanModule> {
|
|
return fetchJSON("/modules", { method: "POST", body: JSON.stringify(body) });
|
|
}
|
|
|
|
export function updateModule(id: string, patch: Partial<ModuleInput>): Promise<KanbanModule> {
|
|
return fetchJSON(`/modules/${id}`, { method: "PATCH", body: JSON.stringify(patch) });
|
|
}
|
|
|
|
export function deleteModule(id: string): Promise<void> {
|
|
return fetchJSON(`/modules/${id}`, { method: "DELETE" });
|
|
}
|
|
|
|
export function listModuleLogs(id: string, limit = 100): Promise<ModuleLog[]> {
|
|
return fetchJSON(`/modules/${id}/logs?limit=${limit}`);
|
|
}
|
|
|
|
export function testModule(idOrDraft: string, body?: ModuleInput): Promise<ModuleTestResult> {
|
|
const init: RequestInit = { method: "POST" };
|
|
if (body) init.body = JSON.stringify(body);
|
|
return fetchJSON(`/modules/${idOrDraft}/test`, init);
|
|
}
|
|
|
|
// streamChat opens a WebSocket, sends the message history, and streams events
|
|
// to onEvent. Returns a Promise that resolves when the server closes the
|
|
// connection (after a "done" event) and rejects on transport errors.
|
|
export function streamChat(
|
|
messages: ChatMessage[],
|
|
onEvent: (ev: ChatStreamEvent) => void,
|
|
signal?: AbortSignal
|
|
): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
const ws = new WebSocket(chatWSURL());
|
|
let settled = false;
|
|
const finish = (err?: Error) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
try {
|
|
ws.close();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
if (err) reject(err);
|
|
else resolve();
|
|
};
|
|
|
|
if (signal) {
|
|
const abort = () => finish(new Error("aborted"));
|
|
if (signal.aborted) {
|
|
abort();
|
|
return;
|
|
}
|
|
signal.addEventListener("abort", abort, { once: true });
|
|
}
|
|
|
|
ws.onopen = () => {
|
|
ws.send(JSON.stringify({ messages }));
|
|
};
|
|
ws.onmessage = (e) => {
|
|
try {
|
|
const ev = JSON.parse(typeof e.data === "string" ? e.data : "") as ChatStreamEvent;
|
|
onEvent(ev);
|
|
if (ev.type === "done" || ev.type === "error") {
|
|
finish(ev.type === "error" ? new Error(ev.error) : undefined);
|
|
}
|
|
} catch (err) {
|
|
finish(err as Error);
|
|
}
|
|
};
|
|
ws.onerror = () => finish(new Error("websocket error"));
|
|
ws.onclose = () => finish();
|
|
});
|
|
}
|
|
|
|
export function login(username: string, password: string): Promise<User> {
|
|
return fetchJSON("/auth/login", {
|
|
method: "POST",
|
|
body: JSON.stringify({ username, password }),
|
|
});
|
|
}
|
|
|
|
export function register(username: string, password: string, display_name?: string): Promise<User> {
|
|
return fetchJSON("/auth/register", {
|
|
method: "POST",
|
|
body: JSON.stringify({ username, password, display_name }),
|
|
});
|
|
}
|
|
|
|
export function logout(): Promise<void> {
|
|
return fetchJSON("/auth/logout", { method: "POST" });
|
|
}
|
|
|
|
export function getMe(): Promise<User> {
|
|
return fetchJSON("/me");
|
|
}
|
|
|
|
export function updateMe(patch: { color?: string }): Promise<User> {
|
|
return fetchJSON("/me", { method: "PATCH", body: JSON.stringify(patch) });
|
|
}
|
|
|
|
export function listUsers(): Promise<User[]> {
|
|
return fetchJSON("/users");
|
|
}
|
|
|
|
export function listTags(): Promise<string[]> {
|
|
return fetchJSON("/tags");
|
|
}
|
|
|
|
export function listRequesters(): Promise<string[]> {
|
|
return fetchJSON("/requesters");
|
|
}
|
|
|
|
// --- Files (issue 0128) -----------------------------------------------------
|
|
|
|
export function listCardFiles(cardId: string): Promise<CardFile[]> {
|
|
return fetchJSON(`/cards/${cardId}/files`);
|
|
}
|
|
|
|
export async function uploadCardFile(
|
|
cardId: string,
|
|
file: File,
|
|
source: "upload" | "description" | "chat" = "upload"
|
|
): Promise<CardFile> {
|
|
const fd = new FormData();
|
|
fd.append("file", file);
|
|
fd.append("source", source);
|
|
const res = await fetch(`${BASE}/cards/${cardId}/files`, {
|
|
method: "POST",
|
|
credentials: "same-origin",
|
|
body: fd,
|
|
});
|
|
if (!res.ok) {
|
|
let msg = `upload failed: ${res.status}`;
|
|
try {
|
|
const body = (await res.json()) as { Message?: string; message?: string };
|
|
if (body.Message || body.message) msg = body.Message || body.message || msg;
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
throw new HTTPError(res.status, msg);
|
|
}
|
|
return (await res.json()) as CardFile;
|
|
}
|
|
|
|
export function deleteCardFile(fileId: string): Promise<void> {
|
|
return fetchJSON(`/files/${fileId}`, { method: "DELETE" });
|
|
}
|
|
|
|
// --- MCP per-user tokens ----------------------------------------------------
|
|
|
|
export interface MCPToken {
|
|
id: string;
|
|
name: string;
|
|
created_at: string;
|
|
last_used_at?: string;
|
|
}
|
|
|
|
export interface MCPTokenCreated extends MCPToken {
|
|
token: string;
|
|
}
|
|
|
|
export function createMCPToken(name: string): Promise<MCPTokenCreated> {
|
|
return fetchJSON("/mcp-tokens", { method: "POST", body: JSON.stringify({ name }) });
|
|
}
|
|
|
|
export function listMCPTokens(): Promise<MCPToken[]> {
|
|
return fetchJSON("/mcp-tokens");
|
|
}
|
|
|
|
export function revokeMCPToken(id: string): Promise<void> {
|
|
return fetchJSON(`/mcp-tokens/${id}`, { method: "DELETE" });
|
|
}
|
|
|
|
export function getMetrics(f: MetricsFilter): Promise<Metrics> {
|
|
const qs = new URLSearchParams();
|
|
if (f.from) qs.set("from", f.from);
|
|
if (f.to) qs.set("to", f.to);
|
|
if (f.assignee_id) qs.set("assignee_id", f.assignee_id);
|
|
if (f.requester) qs.set("requester", f.requester);
|
|
if (f.tags && f.tags.length > 0) qs.set("tags", f.tags.join(","));
|
|
const q = qs.toString();
|
|
return fetchJSON(`/metrics${q ? `?${q}` : ""}`);
|
|
}
|