- Labels management in /settings (not project settings) - getLabels() no longer takes projectId - Auto-assign toggle saves immediately on click - Labels removed from ProjectSettings
494 lines
14 KiB
TypeScript
494 lines
14 KiB
TypeScript
/**
|
|
* API client for Team Board Tracker.
|
|
*/
|
|
|
|
const API_BASE = import.meta.env.VITE_API_URL!; // Required — set in .env
|
|
|
|
function getToken(): string | null {
|
|
if (typeof window === "undefined") return null;
|
|
return localStorage.getItem("tb_token");
|
|
}
|
|
|
|
export function getCurrentSlug(): string {
|
|
const token = getToken();
|
|
if (!token) return "unknown";
|
|
try {
|
|
const payload = JSON.parse(atob(token.split(".")[1]));
|
|
return payload.slug || payload.sub || "unknown";
|
|
} catch {
|
|
return "unknown";
|
|
}
|
|
}
|
|
|
|
async function request<T = any>(path: string, options: RequestInit = {}): Promise<T> {
|
|
const token = getToken();
|
|
const headers: Record<string, string> = {
|
|
"Content-Type": "application/json",
|
|
...(options.headers as Record<string, string>),
|
|
};
|
|
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
|
|
const res = await fetch(`${API_BASE}${path}`, { ...options, headers });
|
|
if (!res.ok) {
|
|
if (res.status === 401 && typeof window !== "undefined") {
|
|
localStorage.removeItem("tb_token");
|
|
window.location.href = "/login";
|
|
throw new Error("Unauthorized");
|
|
}
|
|
const err = await res.json().catch(() => ({ error: res.statusText }));
|
|
throw new Error(err.error || `HTTP ${res.status}`);
|
|
}
|
|
if (res.status === 204) return {} as T;
|
|
return res.json();
|
|
}
|
|
|
|
// --- Types ---
|
|
|
|
// --- Shared types (1:1 with backend Pydantic schemas) ---
|
|
|
|
export interface MemberBrief {
|
|
id: string;
|
|
slug: string;
|
|
name: string;
|
|
}
|
|
|
|
export interface AgentConfig {
|
|
capabilities: string[];
|
|
chat_listen: string;
|
|
task_listen: string;
|
|
prompt: string | null;
|
|
model: string | null;
|
|
}
|
|
|
|
export interface Member {
|
|
id: string;
|
|
slug: string;
|
|
name: string;
|
|
type: string;
|
|
role: string;
|
|
status: string;
|
|
avatar_url: string | null;
|
|
is_active: boolean;
|
|
token?: string | null;
|
|
agent_config?: AgentConfig | null;
|
|
}
|
|
|
|
export interface MemberCreateResponse extends Member {
|
|
token?: string;
|
|
}
|
|
|
|
export interface Project {
|
|
id: string;
|
|
name: string;
|
|
slug: string;
|
|
description: string | null;
|
|
repo_urls: string[];
|
|
status: string;
|
|
task_counter: number;
|
|
chat_id: string | null;
|
|
auto_assign: boolean;
|
|
}
|
|
|
|
export interface ProjectMember {
|
|
id: string;
|
|
name: string;
|
|
slug: string;
|
|
type: string;
|
|
role: string;
|
|
}
|
|
|
|
export interface Step {
|
|
id: string;
|
|
task_id: string;
|
|
title: string;
|
|
done: boolean;
|
|
position: number;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface Task {
|
|
id: string;
|
|
project_id: string;
|
|
parent_id: string | null;
|
|
number: number;
|
|
key: string;
|
|
title: string;
|
|
description: string | null;
|
|
type: string;
|
|
status: string;
|
|
priority: string;
|
|
labels: string[];
|
|
assignee_id: string | null;
|
|
assignee: MemberBrief | null;
|
|
reviewer_id: string | null;
|
|
reviewer: MemberBrief | null;
|
|
watcher_ids: string[];
|
|
depends_on: string[];
|
|
position: number;
|
|
time_spent: number;
|
|
steps: Step[];
|
|
subtasks: SubtaskBrief[];
|
|
parent_key: string | null;
|
|
parent_title: string | null;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface SubtaskBrief {
|
|
id: string;
|
|
key: string;
|
|
title: string;
|
|
status: string;
|
|
assignee: MemberBrief | null;
|
|
}
|
|
|
|
export interface Attachment {
|
|
id: string;
|
|
filename: string;
|
|
mime_type: string | null;
|
|
size: number;
|
|
}
|
|
|
|
export interface Message {
|
|
id: string;
|
|
chat_id: string | null;
|
|
task_id: string | null;
|
|
parent_id: string | null;
|
|
author_type: string;
|
|
author_id: string | null;
|
|
author: MemberBrief | null;
|
|
content: string;
|
|
thinking: string | null;
|
|
tool_log: Array<{name: string; args?: string; result?: string; error?: boolean}> | null;
|
|
mentions: MemberBrief[];
|
|
actor: MemberBrief | null;
|
|
voice_url: string | null;
|
|
attachments: Attachment[];
|
|
created_at: string;
|
|
}
|
|
|
|
// --- Auth ---
|
|
|
|
export async function login(login: string, password: string) {
|
|
return request("/api/v1/auth/login", {
|
|
method: "POST",
|
|
body: JSON.stringify({ login, password }),
|
|
});
|
|
}
|
|
|
|
// --- Projects ---
|
|
|
|
export async function getProjects(): Promise<Project[]> {
|
|
return request("/api/v1/projects");
|
|
}
|
|
|
|
export async function getProject(projectId: string): Promise<Project> {
|
|
return request(`/api/v1/projects/${projectId}`);
|
|
}
|
|
|
|
export async function createProject(data: { name: string; slug: string; description?: string }): Promise<Project> {
|
|
return request("/api/v1/projects", { method: "POST", body: JSON.stringify(data) });
|
|
}
|
|
|
|
export async function updateProject(projectId: string, data: Partial<Pick<Project, "name" | "slug" | "description" | "repo_urls" | "status" | "auto_assign">>): Promise<Project> {
|
|
return request(`/api/v1/projects/${projectId}`, { method: "PATCH", body: JSON.stringify(data) });
|
|
}
|
|
|
|
export async function deleteProject(projectId: string): Promise<void> {
|
|
await request(`/api/v1/projects/${projectId}`, { method: "DELETE" });
|
|
}
|
|
|
|
// --- Tasks ---
|
|
|
|
export async function getTasks(projectId: string): Promise<Task[]> {
|
|
return request(`/api/v1/tasks?project_id=${projectId}`);
|
|
}
|
|
|
|
export async function getTask(taskId: string): Promise<Task> {
|
|
return request(`/api/v1/tasks/${taskId}`);
|
|
}
|
|
|
|
export async function createTask(projectId: string, data: Partial<Task>): Promise<Task> {
|
|
return request(`/api/v1/tasks?project_id=${projectId}`, {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
export async function updateTask(taskId: string, data: Partial<Task>): Promise<Task> {
|
|
return request(`/api/v1/tasks/${taskId}`, { method: "PATCH", body: JSON.stringify(data) });
|
|
}
|
|
|
|
export async function deleteTask(taskId: string): Promise<void> {
|
|
await request(`/api/v1/tasks/${taskId}`, { method: "DELETE" });
|
|
}
|
|
|
|
export async function takeTask(taskId: string): Promise<Task> {
|
|
return request(`/api/v1/tasks/${taskId}/take`, { method: "POST" });
|
|
}
|
|
|
|
export async function rejectTask(taskId: string, reason: string): Promise<{ok: boolean; reason: string; old_assignee: string}> {
|
|
return request(`/api/v1/tasks/${taskId}/reject`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ reason })
|
|
});
|
|
}
|
|
|
|
export async function assignTask(taskId: string, assigneeId: string): Promise<Task> {
|
|
return request(`/api/v1/tasks/${taskId}/assign`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ assignee_id: assigneeId })
|
|
});
|
|
}
|
|
|
|
export async function watchTask(taskId: string): Promise<{ok: boolean; watcher_ids: string[]}> {
|
|
return request(`/api/v1/tasks/${taskId}/watch`, { method: "POST" });
|
|
}
|
|
|
|
export async function unwatchTask(taskId: string): Promise<{ok: boolean; watcher_ids: string[]}> {
|
|
return request(`/api/v1/tasks/${taskId}/watch`, { method: "DELETE" });
|
|
}
|
|
|
|
// --- Steps ---
|
|
|
|
export async function getSteps(taskId: string): Promise<Step[]> {
|
|
return request(`/api/v1/tasks/${taskId}/steps`);
|
|
}
|
|
|
|
export async function createStep(taskId: string, title: string): Promise<Step> {
|
|
return request(`/api/v1/tasks/${taskId}/steps`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ title }),
|
|
});
|
|
}
|
|
|
|
export async function updateStep(taskId: string, stepId: string, data: Partial<Step>): Promise<Step> {
|
|
return request(`/api/v1/tasks/${taskId}/steps/${stepId}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
export async function deleteStep(taskId: string, stepId: string): Promise<void> {
|
|
await request(`/api/v1/tasks/${taskId}/steps/${stepId}`, { method: "DELETE" });
|
|
}
|
|
|
|
// --- Messages (unified: chat + task comments) ---
|
|
|
|
export async function getMessages(params: { chat_id?: string; task_id?: string; limit?: number; offset?: number }): Promise<Message[]> {
|
|
const qs = new URLSearchParams();
|
|
if (params.chat_id) qs.set("chat_id", params.chat_id);
|
|
if (params.task_id) qs.set("task_id", params.task_id);
|
|
if (params.limit) qs.set("limit", String(params.limit));
|
|
if (params.offset) qs.set("offset", String(params.offset));
|
|
return request(`/api/v1/messages?${qs}`);
|
|
}
|
|
|
|
export async function sendMessage(data: {
|
|
chat_id?: string;
|
|
task_id?: string;
|
|
content: string;
|
|
mentions?: string[];
|
|
attachments?: { file_id: string; filename: string; mime_type: string | null; size: number; storage_name: string }[];
|
|
}): Promise<Message> {
|
|
return request("/api/v1/messages", { method: "POST", body: JSON.stringify(data) });
|
|
}
|
|
|
|
// --- Members ---
|
|
|
|
export async function getMembers(): Promise<Member[]> {
|
|
return request("/api/v1/members");
|
|
}
|
|
|
|
export async function getMember(memberId: string): Promise<Member> {
|
|
return request(`/api/v1/members/${memberId}`);
|
|
}
|
|
|
|
export async function createMember(data: {
|
|
name: string;
|
|
slug: string;
|
|
type?: string;
|
|
agent_config?: Partial<AgentConfig>;
|
|
}): Promise<MemberCreateResponse> {
|
|
return request("/api/v1/members", { method: "POST", body: JSON.stringify(data) });
|
|
}
|
|
|
|
export async function updateMember(memberId: string, data: {
|
|
name?: string;
|
|
role?: string;
|
|
status?: string;
|
|
agent_config?: Partial<AgentConfig>;
|
|
}): Promise<Member> {
|
|
return request(`/api/v1/members/${memberId}`, { method: "PATCH", body: JSON.stringify(data) });
|
|
}
|
|
|
|
export async function regenerateToken(memberId: string): Promise<{ token: string }> {
|
|
return request(`/api/v1/members/${memberId}/regenerate-token`, { method: "POST" });
|
|
}
|
|
|
|
export async function revokeToken(memberId: string): Promise<void> {
|
|
await request(`/api/v1/members/${memberId}/revoke-token`, { method: "POST" });
|
|
}
|
|
|
|
// --- Project Files ---
|
|
|
|
export interface ProjectFile {
|
|
id: string;
|
|
filename: string;
|
|
description: string | null;
|
|
mime_type: string | null;
|
|
size: number;
|
|
uploaded_by: MemberBrief | null;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export async function getProjectFiles(projectId: string, search?: string): Promise<ProjectFile[]> {
|
|
const qs = search ? `?search=${encodeURIComponent(search)}` : "";
|
|
return request(`/api/v1/projects/${projectId}/files${qs}`);
|
|
}
|
|
|
|
export async function uploadProjectFile(projectId: string, file: File, description?: string): Promise<ProjectFile> {
|
|
const token = getToken();
|
|
const formData = new FormData();
|
|
formData.append("file", file);
|
|
if (description) formData.append("description", description);
|
|
|
|
const res = await fetch(`${API_BASE}/api/v1/projects/${projectId}/files`, {
|
|
method: "POST",
|
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
|
body: formData,
|
|
});
|
|
if (res.status === 401) {
|
|
localStorage.removeItem("tb_token");
|
|
window.location.href = "/login";
|
|
throw new Error("Unauthorized");
|
|
}
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ error: res.statusText }));
|
|
throw new Error(err.error || `HTTP ${res.status}`);
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
export function getProjectFileUrl(projectId: string, fileId: string): string {
|
|
const token = getToken();
|
|
const qs = token ? `?token=${encodeURIComponent(token)}` : "";
|
|
return `${API_BASE}/api/v1/projects/${projectId}/files/${fileId}/download${qs}`;
|
|
}
|
|
|
|
export async function updateProjectFile(projectId: string, fileId: string, data: { description?: string }): Promise<ProjectFile> {
|
|
return request(`/api/v1/projects/${projectId}/files/${fileId}`, { method: "PATCH", body: JSON.stringify(data) });
|
|
}
|
|
|
|
export async function deleteProjectFile(projectId: string, fileId: string): Promise<void> {
|
|
await request(`/api/v1/projects/${projectId}/files/${fileId}`, { method: "DELETE" });
|
|
}
|
|
|
|
// --- Chat File Upload ---
|
|
|
|
export interface UploadedFile {
|
|
file_id: string;
|
|
filename: string;
|
|
mime_type: string | null;
|
|
size: number;
|
|
storage_name: string;
|
|
}
|
|
|
|
export async function uploadFile(file: File): Promise<UploadedFile> {
|
|
const token = getToken();
|
|
const formData = new FormData();
|
|
formData.append("file", file);
|
|
|
|
const res = await fetch(`${API_BASE}/api/v1/upload`, {
|
|
method: "POST",
|
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
|
body: formData,
|
|
});
|
|
if (res.status === 401) {
|
|
localStorage.removeItem("tb_token");
|
|
window.location.href = "/login";
|
|
throw new Error("Unauthorized");
|
|
}
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ error: res.statusText }));
|
|
throw new Error(err.error || `HTTP ${res.status}`);
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
export function getAttachmentUrl(attachmentId: string): string {
|
|
const token = getToken();
|
|
const qs = token ? `?token=${encodeURIComponent(token)}` : "";
|
|
return `${API_BASE}/api/v1/attachments/${attachmentId}/download${qs}`;
|
|
}
|
|
|
|
// --- Project Members ---
|
|
|
|
export async function getProjectMembers(projectId: string): Promise<ProjectMember[]> {
|
|
return request(`/api/v1/projects/${projectId}/members`);
|
|
}
|
|
|
|
export async function addProjectMember(projectId: string, memberId: string): Promise<{ ok: boolean }> {
|
|
return request(`/api/v1/projects/${projectId}/members`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ member_id: memberId })
|
|
});
|
|
}
|
|
|
|
export async function removeProjectMember(projectId: string, memberId: string): Promise<{ ok: boolean }> {
|
|
return request(`/api/v1/projects/${projectId}/members/${memberId}`, { method: "DELETE" });
|
|
}
|
|
// --- Task Links ---
|
|
export interface TaskLink {
|
|
id: string;
|
|
source_id: string;
|
|
target_id: string;
|
|
link_type: string;
|
|
target_title?: string;
|
|
source_title?: string;
|
|
}
|
|
|
|
export async function getTaskLinks(taskId: string): Promise<TaskLink[]> {
|
|
return request(`/tasks/${taskId}/links`);
|
|
}
|
|
|
|
export async function createTaskLink(taskId: string, targetId: string, linkType: string): Promise<TaskLink> {
|
|
return request(`/tasks/${taskId}/links`, { method: "POST", body: JSON.stringify({ target_id: targetId, link_type: linkType }) });
|
|
}
|
|
|
|
export async function deleteTaskLink(taskId: string, linkId: string): Promise<void> {
|
|
return request(`/tasks/${taskId}/links/${linkId}`, { method: "DELETE" });
|
|
}
|
|
|
|
// --- Labels ---
|
|
export interface Label {
|
|
id: string;
|
|
name: string;
|
|
color: string;
|
|
}
|
|
|
|
export async function getLabels(): Promise<Label[]> {
|
|
return request(`/labels`);
|
|
}
|
|
|
|
export async function createLabel(name: string, color: string = "#6366f1"): Promise<Label> {
|
|
return request(`/labels`, { method: "POST", body: JSON.stringify({ name, color }) });
|
|
}
|
|
|
|
export async function deleteLabel(labelId: string): Promise<void> {
|
|
return request(`/labels/${labelId}`, { method: "DELETE" });
|
|
}
|
|
|
|
export async function addTaskLabel(taskId: string, labelId: string): Promise<void> {
|
|
return request(`/tasks/${taskId}/labels/${labelId}`, { method: "POST" });
|
|
}
|
|
|
|
export async function removeTaskLabel(taskId: string, labelId: string): Promise<void> {
|
|
return request(`/tasks/${taskId}/labels/${labelId}`, { method: "DELETE" });
|
|
}
|
|
|
|
export async function searchTasks(projectId: string, query: string): Promise<Task[]> {
|
|
return request(`/tasks?project_id=${projectId}&q=${encodeURIComponent(query)}`);
|
|
}
|