v0.2.0: updated for new Tracker API
Some checks failed
Deploy Web Client / deploy (push) Failing after 9s

- api.ts: Member, Unified Message, Steps, new types
- ws.ts: auth.ok handling, project.subscribe, chat.send
- KanbanBoard: new statuses (backlog/todo/in_progress/in_review/done)
- TaskModal: steps checklist, comments (unified messages), member select
- ChatPanel: unified messages, WS events
- BFF: all new endpoints (members, messages, steps, task actions)
- BFF ws_proxy: token-based auth to tracker
This commit is contained in:
Markov 2026-02-22 17:40:13 +01:00
parent cb5b2afe16
commit 7185a50f57
8 changed files with 653 additions and 505 deletions

View File

@ -3,7 +3,7 @@
import logging import logging
import time import time
from fastapi import FastAPI, Request, Depends from fastapi import FastAPI, Request, Depends, Query
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from pydantic import BaseModel from pydantic import BaseModel
@ -19,9 +19,8 @@ logging.basicConfig(
) )
logger = logging.getLogger("bff") logger = logging.getLogger("bff")
app = FastAPI(title="Team Board BFF", version="0.1.0") app = FastAPI(title="Team Board BFF", version="0.2.0")
# CORS
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["https://team.uix.su", "http://localhost:3100"], allow_origins=["https://team.uix.su", "http://localhost:3100"],
@ -30,8 +29,6 @@ app.add_middleware(
) )
# --- Request logging ---
@app.middleware("http") @app.middleware("http")
async def log_requests(request: Request, call_next): async def log_requests(request: Request, call_next):
start = time.time() start = time.time()
@ -57,61 +54,68 @@ class LoginRequest(BaseModel):
async def login(data: LoginRequest): async def login(data: LoginRequest):
if data.username == AUTH_USER and data.password == AUTH_PASS: if data.username == AUTH_USER and data.password == AUTH_PASS:
token = create_token(data.username) token = create_token(data.username)
return {"token": token, "user": {"name": data.username, "provider": "local"}} return {"token": token, "user": {"name": data.username, "slug": "admin"}}
return JSONResponse({"error": "Invalid credentials"}, status_code=401) return JSONResponse({"error": "Invalid credentials"}, status_code=401)
@app.get("/api/auth/me") @app.get("/api/auth/me")
async def me(user: dict = Depends(get_current_user)): async def me(user: dict = Depends(get_current_user)):
return {"user": {"name": user["name"], "provider": user["provider"]}} return {"user": {"name": user["name"], "slug": user.get("sub", "admin")}}
# --- Proxy: Projects --- # --- Proxy: Projects ---
@app.get("/api/v1/projects/") @app.get("/api/v1/projects")
async def list_projects(user: dict = Depends(get_current_user)): async def list_projects(user: dict = Depends(get_current_user)):
return await tracker_get("/api/v1/projects/") return await tracker_get("/api/v1/projects")
@app.post("/api/v1/projects/") @app.post("/api/v1/projects")
async def create_project(request: Request, user: dict = Depends(get_current_user)): async def create_project(request: Request, user: dict = Depends(get_current_user)):
body = await request.json() body = await request.json()
return await tracker_post("/api/v1/projects/", json=body) return await tracker_post("/api/v1/projects", json=body)
@app.get("/api/v1/projects/{project_id}") @app.get("/api/v1/projects/{slug}")
async def get_project(project_id: str, user: dict = Depends(get_current_user)): async def get_project(slug: str, user: dict = Depends(get_current_user)):
return await tracker_get(f"/api/v1/projects/{project_id}") return await tracker_get(f"/api/v1/projects/{slug}")
@app.patch("/api/v1/projects/{project_id}") @app.patch("/api/v1/projects/{slug}")
async def update_project(project_id: str, request: Request, user: dict = Depends(get_current_user)): async def update_project(slug: str, request: Request, user: dict = Depends(get_current_user)):
body = await request.json() body = await request.json()
return await tracker_patch(f"/api/v1/projects/{project_id}", json=body) return await tracker_patch(f"/api/v1/projects/{slug}", json=body)
@app.delete("/api/v1/projects/{project_id}") @app.delete("/api/v1/projects/{slug}")
async def delete_project(project_id: str, user: dict = Depends(get_current_user)): async def delete_project(slug: str, user: dict = Depends(get_current_user)):
await tracker_delete(f"/api/v1/projects/{project_id}") return await tracker_delete(f"/api/v1/projects/{slug}")
return JSONResponse(status_code=204)
# --- Proxy: Tasks --- # --- Proxy: Tasks ---
@app.get("/api/v1/tasks/") @app.get("/api/v1/tasks")
async def list_tasks(project_id: str = None, status: str = None, user: dict = Depends(get_current_user)): async def list_tasks(
project_id: str = None, status: str = None,
assignee: str = None, label: str = None,
user: dict = Depends(get_current_user),
):
params = {} params = {}
if project_id: if project_id:
params["project_id"] = project_id params["project_id"] = project_id
if status: if status:
params["status"] = status params["status"] = status
return await tracker_get("/api/v1/tasks/", params=params) if assignee:
params["assignee"] = assignee
if label:
params["label"] = label
return await tracker_get("/api/v1/tasks", params=params)
@app.post("/api/v1/tasks/") @app.post("/api/v1/tasks")
async def create_task(request: Request, user: dict = Depends(get_current_user)): async def create_task(request: Request, project_slug: str = Query(...), user: dict = Depends(get_current_user)):
body = await request.json() body = await request.json()
return await tracker_post("/api/v1/tasks/", json=body) return await tracker_post(f"/api/v1/tasks?project_slug={project_slug}", json=body)
@app.get("/api/v1/tasks/{task_id}") @app.get("/api/v1/tasks/{task_id}")
@ -127,73 +131,120 @@ async def update_task(task_id: str, request: Request, user: dict = Depends(get_c
@app.delete("/api/v1/tasks/{task_id}") @app.delete("/api/v1/tasks/{task_id}")
async def delete_task(task_id: str, user: dict = Depends(get_current_user)): async def delete_task(task_id: str, user: dict = Depends(get_current_user)):
await tracker_delete(f"/api/v1/tasks/{task_id}") return await tracker_delete(f"/api/v1/tasks/{task_id}")
return JSONResponse(status_code=204)
# --- Proxy: Agents --- @app.post("/api/v1/tasks/{task_id}/take")
async def take_task(task_id: str, slug: str = Query(...), user: dict = Depends(get_current_user)):
@app.get("/api/v1/agents/") return await tracker_post(f"/api/v1/tasks/{task_id}/take?slug={slug}")
async def list_agents(user: dict = Depends(get_current_user)):
return await tracker_get("/api/v1/agents/")
# --- Proxy: Chats --- @app.post("/api/v1/tasks/{task_id}/reject")
async def reject_task(task_id: str, request: Request, user: dict = Depends(get_current_user)):
@app.get("/api/v1/chats/lobby")
async def get_lobby(user: dict = Depends(get_current_user)):
return await tracker_get("/api/v1/chats/lobby")
@app.get("/api/v1/chats/project/{project_id}")
async def get_project_chat(project_id: str, user: dict = Depends(get_current_user)):
return await tracker_get(f"/api/v1/chats/project/{project_id}")
@app.get("/api/v1/chats/{chat_id}/messages")
async def list_messages(chat_id: str, limit: int = 50, before: str = None, user: dict = Depends(get_current_user)):
params = {"limit": limit}
if before:
params["before"] = before
return await tracker_get(f"/api/v1/chats/{chat_id}/messages", params=params)
@app.post("/api/v1/chats/{chat_id}/messages")
async def create_message(chat_id: str, request: Request, user: dict = Depends(get_current_user)):
body = await request.json() body = await request.json()
# Inject sender info from auth return await tracker_post(f"/api/v1/tasks/{task_id}/reject", json=body)
body.setdefault("sender_type", "human")
body.setdefault("sender_name", user.get("name", "human"))
return await tracker_post(f"/api/v1/chats/{chat_id}/messages", json=body)
# --- Proxy: Labels --- @app.post("/api/v1/tasks/{task_id}/assign")
async def assign_task(task_id: str, request: Request, user: dict = Depends(get_current_user)):
@app.get("/api/v1/labels/")
async def list_labels(user: dict = Depends(get_current_user)):
return await tracker_get("/api/v1/labels/")
@app.post("/api/v1/labels/")
async def create_label(request: Request, user: dict = Depends(get_current_user)):
body = await request.json() body = await request.json()
return await tracker_post("/api/v1/labels/", json=body) return await tracker_post(f"/api/v1/tasks/{task_id}/assign", json=body)
@app.post("/api/v1/tasks/{task_id}/watch")
async def watch_task(task_id: str, slug: str = Query(...), user: dict = Depends(get_current_user)):
return await tracker_post(f"/api/v1/tasks/{task_id}/watch?slug={slug}")
@app.delete("/api/v1/tasks/{task_id}/watch")
async def unwatch_task(task_id: str, slug: str = Query(...), user: dict = Depends(get_current_user)):
return await tracker_delete(f"/api/v1/tasks/{task_id}/watch?slug={slug}")
# --- Proxy: Steps ---
@app.get("/api/v1/tasks/{task_id}/steps")
async def list_steps(task_id: str, user: dict = Depends(get_current_user)):
return await tracker_get(f"/api/v1/tasks/{task_id}/steps")
@app.post("/api/v1/tasks/{task_id}/steps")
async def create_step(task_id: str, request: Request, user: dict = Depends(get_current_user)):
body = await request.json()
return await tracker_post(f"/api/v1/tasks/{task_id}/steps", json=body)
@app.patch("/api/v1/tasks/{task_id}/steps/{step_id}")
async def update_step(task_id: str, step_id: str, request: Request, user: dict = Depends(get_current_user)):
body = await request.json()
return await tracker_patch(f"/api/v1/tasks/{task_id}/steps/{step_id}", json=body)
@app.delete("/api/v1/tasks/{task_id}/steps/{step_id}")
async def delete_step(task_id: str, step_id: str, user: dict = Depends(get_current_user)):
return await tracker_delete(f"/api/v1/tasks/{task_id}/steps/{step_id}")
# --- Proxy: Messages (unified) ---
@app.get("/api/v1/messages")
async def list_messages(
chat_id: str = None, task_id: str = None,
limit: int = 50, offset: int = 0,
user: dict = Depends(get_current_user),
):
params = {"limit": limit, "offset": offset}
if chat_id:
params["chat_id"] = chat_id
if task_id:
params["task_id"] = task_id
return await tracker_get("/api/v1/messages", params=params)
@app.post("/api/v1/messages")
async def create_message(request: Request, user: dict = Depends(get_current_user)):
body = await request.json()
# Inject author from auth
body.setdefault("author_type", "human")
body.setdefault("author_slug", user.get("sub", "admin"))
return await tracker_post("/api/v1/messages", json=body)
# --- Proxy: Members ---
@app.get("/api/v1/members")
async def list_members(user: dict = Depends(get_current_user)):
return await tracker_get("/api/v1/members")
@app.get("/api/v1/members/{slug}")
async def get_member(slug: str, user: dict = Depends(get_current_user)):
return await tracker_get(f"/api/v1/members/{slug}")
@app.post("/api/v1/members")
async def create_member(request: Request, user: dict = Depends(get_current_user)):
body = await request.json()
return await tracker_post("/api/v1/members", json=body)
@app.patch("/api/v1/members/{slug}")
async def update_member(slug: str, request: Request, user: dict = Depends(get_current_user)):
body = await request.json()
return await tracker_patch(f"/api/v1/members/{slug}", json=body)
# --- Health --- # --- Health ---
@app.get("/health") @app.get("/health")
async def health(): async def health():
return {"status": "ok", "service": "bff", "version": "0.1.0"} return {"status": "ok", "service": "bff", "version": "0.2.0"}
# --- WebSocket --- # WebSocket proxy
app.include_router(ws_router) app.include_router(ws_router)
# --- Shutdown ---
@app.on_event("shutdown") @app.on_event("shutdown")
async def shutdown(): async def shutdown():
await close_client() await close_client()

View File

@ -1,8 +1,8 @@
"""WebSocket proxy: authenticated users ↔ Tracker.""" """WebSocket proxy: authenticated web users ↔ Tracker WS."""
import asyncio import asyncio
import logging
import json import json
import logging
from fastapi import APIRouter, WebSocket, WebSocketDisconnect from fastapi import APIRouter, WebSocket, WebSocketDisconnect
import websockets import websockets
@ -26,12 +26,30 @@ async def ws_proxy(ws: WebSocket, token: str = ""):
return return
await ws.accept() await ws.accept()
logger.info("WS connected: %s", user.get("name")) user_name = user.get("name", "unknown")
user_slug = user.get("sub", "admin")
logger.info("WS connected: %s (%s)", user_name, user_slug)
# Connect to tracker
tracker_url = f"{TRACKER_WS_URL}?client_type=human&client_id={user['sub']}&token={TRACKER_TOKEN}"
try: try:
async with websockets.connect(tracker_url) as tracker_ws: async with websockets.connect(TRACKER_WS_URL) as tracker_ws:
# Send auth to tracker on behalf of user
auth_msg = json.dumps({
"type": "auth",
"token": TRACKER_TOKEN,
})
await tracker_ws.send(auth_msg)
# Wait for auth.ok
auth_resp = await tracker_ws.recv()
auth_data = json.loads(auth_resp)
if auth_data.get("type") != "auth.ok":
logger.error("Tracker auth failed: %s", auth_data)
await ws.close(code=4002, reason="Tracker auth failed")
return
# Forward auth.ok to client
await ws.send_text(auth_resp)
async def forward_to_tracker(): async def forward_to_tracker():
try: try:
while True: while True:
@ -51,4 +69,4 @@ async def ws_proxy(ws: WebSocket, token: str = ""):
except Exception as e: except Exception as e:
logger.error("WS proxy error: %s", e) logger.error("WS proxy error: %s", e)
finally: finally:
logger.info("WS disconnected: %s", user.get("name")) logger.info("WS disconnected: %s", user_name)

View File

@ -43,7 +43,7 @@ export default function ProjectPage() {
</header> </header>
<ChatPanel projectId={project.id} /> <ChatPanel projectId={project.id} />
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
<KanbanBoard projectId={project.id} /> <KanbanBoard projectId={project.id} projectSlug={project.slug} />
</div> </div>
</main> </main>
</div> </div>

View File

@ -1,186 +1,90 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { import { Message, getMessages } from "@/lib/api";
Chat,
ChatMessage,
getProjectChat,
getChatMessages,
} from "@/lib/api";
import { wsClient } from "@/lib/ws"; import { wsClient } from "@/lib/ws";
interface ChatPanelProps { const AUTHOR_ICON: Record<string, string> = {
human: "👤",
agent: "🤖",
system: "⚙️",
};
interface Props {
projectId: string; projectId: string;
} }
export default function ChatPanel({ projectId }: ChatPanelProps) { export default function ChatPanel({ projectId }: Props) {
const [chat, setChat] = useState<Chat | null>(null); const [messages, setMessages] = useState<Message[]>([]);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [expanded, setExpanded] = useState(false); const [chatId, setChatId] = useState<string | null>(null);
const [collapsed, setCollapsed] = useState(false); const [open, setOpen] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null); const bottomRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Load chat and messages // Find project chat ID from WS auth data or use lobby
useEffect(() => { useEffect(() => {
let cancelled = false; // For now use lobby
getProjectChat(projectId).then((c) => { const id = wsClient.lobbyId;
if (cancelled) return; if (id) {
setChat(c); setChatId(id);
getChatMessages(c.id).then((msgs) => { getMessages({ chat_id: id, limit: 50 }).then(setMessages).catch(() => {});
if (!cancelled) setMessages(msgs); }
}); }, []);
});
return () => { cancelled = true; };
}, [projectId]);
// WebSocket: subscribe to chat room // Listen for new messages
useEffect(() => { useEffect(() => {
if (!chat) return; const unsub = wsClient.on("message.new", (data: any) => {
if (data.chat_id === chatId) {
wsClient.connect(); setMessages((prev) => [...prev, data as Message]);
wsClient.subscribeChat(chat.id);
const unsub = wsClient.on("chat.message", (msg: any) => {
if (msg.chat_id === chat.id) {
setMessages((prev) => [
...prev,
{
id: msg.id,
chat_id: msg.chat_id,
sender_type: msg.sender_type,
sender_id: msg.sender_id,
sender_name: msg.sender_name,
content: msg.content,
created_at: msg.created_at,
},
]);
} }
}); });
return unsub;
}, [chatId]);
return () => {
wsClient.unsubscribeChat(chat.id);
unsub();
};
}, [chat]);
// Auto-scroll
useEffect(() => { useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]); }, [messages]);
const handleSend = () => { const handleSend = () => {
if (!chat || !input.trim()) return; if (!input.trim() || !chatId) return;
const text = input.trim(); wsClient.sendChat(chatId, input.trim());
setInput(""); setInput("");
// Send via WebSocket — message will come back via chat.message event
wsClient.send("chat.send", {
chat_id: chat.id,
content: text,
sender_type: "human",
sender_name: "admin",
});
inputRef.current?.focus();
}; };
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
if (collapsed) {
return ( return (
<div className="border-b border-[var(--border)] px-4 py-2 flex items-center justify-between bg-[var(--bg-secondary)]"> <div className={`border-b border-[var(--border)] transition-all ${open ? "h-64" : "h-10"}`}>
<button <button
onClick={() => setCollapsed(false)} onClick={() => setOpen(!open)}
className="text-sm text-[var(--muted)] hover:text-[var(--fg)] flex items-center gap-2" className="w-full h-10 px-4 flex items-center text-sm text-[var(--muted)] hover:text-[var(--fg)]"
> >
💬 Чат проекта 💬 Чат {open ? "▼" : "▲"}
{messages.length > 0 && (
<span className="bg-[var(--accent)] text-white text-xs px-1.5 py-0.5 rounded-full">
{messages.length}
</span>
)}
</button> </button>
</div>
);
}
const panelHeight = expanded ? "h-full" : "h-[30vh]"; {open && (
<div className="flex flex-col h-[calc(100%-2.5rem)]">
return (
<div className={`${panelHeight} border-b border-[var(--border)] flex flex-col bg-[var(--bg-secondary)] transition-all duration-200`}>
{/* Header */}
<div className="flex items-center justify-between px-4 py-2 border-b border-[var(--border)]">
<span className="text-sm font-medium">💬 Чат проекта</span>
<div className="flex items-center gap-1">
<button
onClick={() => setExpanded(!expanded)}
className="text-[var(--muted)] hover:text-[var(--fg)] p-1 text-sm"
title={expanded ? "Свернуть" : "Развернуть"}
>
{expanded ? "⬆️" : "⬇️"}
</button>
<button
onClick={() => { setCollapsed(true); setExpanded(false); }}
className="text-[var(--muted)] hover:text-[var(--fg)] p-1 text-sm"
title="Скрыть"
>
</button>
</div>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto px-4 py-2 space-y-2"> <div className="flex-1 overflow-y-auto px-4 py-2 space-y-2">
{messages.length === 0 && (
<p className="text-sm text-[var(--muted)] text-center py-4">
Пока пусто. Напишите первое сообщение 💬
</p>
)}
{messages.map((msg) => ( {messages.map((msg) => (
<div key={msg.id} className="flex gap-2 text-sm"> <div key={msg.id} className="text-sm">
<span className={`font-medium shrink-0 ${ <span className="text-xs text-[var(--muted)]">
msg.sender_type === "agent" ? "text-blue-400" : {AUTHOR_ICON[msg.author_type] || "👤"} {msg.author_slug}
msg.sender_type === "system" ? "text-yellow-500" :
"text-green-400"
}`}>
{msg.sender_name || msg.sender_type}:
</span>
<span className="text-[var(--fg)] break-words">{msg.content}</span>
<span className="text-[var(--muted)] text-xs shrink-0 self-end">
{new Date(msg.created_at).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" })}
</span> </span>
<span className="ml-2">{msg.content}</span>
</div> </div>
))} ))}
<div ref={messagesEndRef} /> <div ref={bottomRef} />
</div> </div>
{/* Input */}
<div className="px-4 py-2 border-t border-[var(--border)] flex gap-2"> <div className="px-4 py-2 border-t border-[var(--border)] flex gap-2">
<input <input
ref={inputRef}
type="text"
value={input} value={input}
onChange={(e) => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={(e) => e.key === "Enter" && handleSend()}
placeholder="Написать сообщение..." placeholder="Сообщение..."
className="flex-1 bg-[var(--bg)] border border-[var(--border)] rounded px-3 py-1.5 text-sm outline-none focus:border-[var(--accent)]" className="flex-1 bg-[var(--bg)] border border-[var(--border)] rounded px-3 py-1.5 text-sm outline-none focus:border-[var(--accent)]"
disabled={!chat}
/> />
<button <button onClick={handleSend} className="text-sm text-[var(--accent)] px-2"></button>
onClick={handleSend}
disabled={!chat || !input.trim()}
className="bg-[var(--accent)] text-white px-4 py-1.5 rounded text-sm hover:opacity-90 disabled:opacity-50"
>
</button>
</div> </div>
</div> </div>
)}
</div>
); );
} }

View File

@ -5,11 +5,11 @@ import { Task, getTasks, updateTask, createTask } from "@/lib/api";
import TaskModal from "@/components/TaskModal"; import TaskModal from "@/components/TaskModal";
const COLUMNS = [ const COLUMNS = [
{ key: "draft", label: "Backlog", color: "#737373" }, { key: "backlog", label: "Backlog", color: "#737373" },
{ key: "ready", label: "TODO", color: "#3b82f6" }, { key: "todo", label: "TODO", color: "#3b82f6" },
{ key: "in_progress", label: "In Progress", color: "#f59e0b" }, { key: "in_progress", label: "In Progress", color: "#f59e0b" },
{ key: "review", label: "Review", color: "#a855f7" }, { key: "in_review", label: "Review", color: "#a855f7" },
{ key: "completed", label: "Done", color: "#22c55e" }, { key: "done", label: "Done", color: "#22c55e" },
]; ];
const PRIORITY_COLORS: Record<string, string> = { const PRIORITY_COLORS: Record<string, string> = {
@ -21,9 +21,10 @@ const PRIORITY_COLORS: Record<string, string> = {
interface Props { interface Props {
projectId: string; projectId: string;
projectSlug: string;
} }
export default function KanbanBoard({ projectId }: Props) { export default function KanbanBoard({ projectId, projectSlug }: Props) {
const [tasks, setTasks] = useState<Task[]>([]); const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [newTaskTitle, setNewTaskTitle] = useState(""); const [newTaskTitle, setNewTaskTitle] = useState("");
@ -72,8 +73,7 @@ export default function KanbanBoard({ projectId }: Props) {
const handleAddTask = async (status: string) => { const handleAddTask = async (status: string) => {
if (!newTaskTitle.trim()) return; if (!newTaskTitle.trim()) return;
try { try {
const task = await createTask({ const task = await createTask(projectSlug, {
project_id: projectId,
title: newTaskTitle.trim(), title: newTaskTitle.trim(),
status, status,
}); });
@ -102,7 +102,7 @@ export default function KanbanBoard({ projectId }: Props) {
key={col.key} key={col.key}
onClick={() => setActiveColumn(col.key)} onClick={() => setActiveColumn(col.key)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs whitespace-nowrap shrink-0 transition-colors className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs whitespace-nowrap shrink-0 transition-colors
${(activeColumn || "draft") === col.key ${(activeColumn || "backlog") === col.key
? "bg-white/10 text-[var(--fg)]" ? "bg-white/10 text-[var(--fg)]"
: "text-[var(--muted)]" : "text-[var(--muted)]"
}`} }`}
@ -118,7 +118,7 @@ export default function KanbanBoard({ projectId }: Props) {
{/* Mobile: single column view */} {/* Mobile: single column view */}
<div className="md:hidden flex-1 overflow-y-auto p-3"> <div className="md:hidden flex-1 overflow-y-auto p-3">
{(() => { {(() => {
const col = COLUMNS.find((c) => c.key === (activeColumn || "draft"))!; const col = COLUMNS.find((c) => c.key === (activeColumn || "backlog"))!;
const colTasks = tasks.filter((t) => t.status === col.key); const colTasks = tasks.filter((t) => t.status === col.key);
const colIndex = COLUMNS.findIndex((c) => c.key === col.key); const colIndex = COLUMNS.findIndex((c) => c.key === col.key);
return ( return (
@ -135,7 +135,7 @@ export default function KanbanBoard({ projectId }: Props) {
className="w-2 h-2 rounded-full shrink-0" className="w-2 h-2 rounded-full shrink-0"
style={{ background: PRIORITY_COLORS[task.priority] || "#737373" }} style={{ background: PRIORITY_COLORS[task.priority] || "#737373" }}
/> />
<span className="text-xs text-[var(--muted)]">{task.key}</span> <span className="text-xs text-[var(--muted)]">{`#${task.number}`}</span>
</div> </div>
<div className="text-sm ml-3.5">{task.title}</div> <div className="text-sm ml-3.5">{task.title}</div>
{/* Move buttons */} {/* Move buttons */}
@ -222,7 +222,7 @@ export default function KanbanBoard({ projectId }: Props) {
style={{ background: PRIORITY_COLORS[task.priority] || "#737373" }} style={{ background: PRIORITY_COLORS[task.priority] || "#737373" }}
title={task.priority} title={task.priority}
/> />
<span className="text-xs text-[var(--muted)]">{task.key}</span> <span className="text-xs text-[var(--muted)]">{`#${task.number}`}</span>
</div> </div>
<div className="text-sm ml-3.5">{task.title}</div> <div className="text-sm ml-3.5">{task.title}</div>
</div> </div>

View File

@ -1,15 +1,19 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Task, Agent, updateTask, deleteTask, getAgents, getTasks } from "@/lib/api"; import {
Task, Member, Step, Message,
updateTask, deleteTask, getMembers,
getSteps, createStep, updateStep, deleteStep as deleteStepApi,
getMessages, sendMessage,
} from "@/lib/api";
const STATUSES = [ const STATUSES = [
{ key: "draft", label: "Backlog", color: "#737373" }, { key: "backlog", label: "Backlog", color: "#737373" },
{ key: "ready", label: "TODO", color: "#3b82f6" }, { key: "todo", label: "TODO", color: "#3b82f6" },
{ key: "in_progress", label: "In Progress", color: "#f59e0b" }, { key: "in_progress", label: "In Progress", color: "#f59e0b" },
{ key: "review", label: "Review", color: "#a855f7" }, { key: "in_review", label: "Review", color: "#a855f7" },
{ key: "completed", label: "Done", color: "#22c55e" }, { key: "done", label: "Done", color: "#22c55e" },
{ key: "blocked", label: "Blocked", color: "#ef4444" },
]; ];
const PRIORITIES = [ const PRIORITIES = [
@ -19,6 +23,12 @@ const PRIORITIES = [
{ key: "critical", label: "Critical", color: "#ef4444" }, { key: "critical", label: "Critical", color: "#ef4444" },
]; ];
const AUTHOR_ICON: Record<string, string> = {
human: "👤",
agent: "🤖",
system: "⚙️",
};
interface Props { interface Props {
task: Task; task: Task;
projectId: string; projectId: string;
@ -32,18 +42,22 @@ export default function TaskModal({ task, projectId, onClose, onUpdated, onDelet
const [description, setDescription] = useState(task.description || ""); const [description, setDescription] = useState(task.description || "");
const [status, setStatus] = useState(task.status); const [status, setStatus] = useState(task.status);
const [priority, setPriority] = useState(task.priority); const [priority, setPriority] = useState(task.priority);
const [assignedAgentId, setAssignedAgentId] = useState(task.assigned_agent_id || ""); const [assigneeSlug, setAssigneeSlug] = useState(task.assignee_slug || "");
const [agents, setAgents] = useState<Agent[]>([]); const [members, setMembers] = useState<Member[]>([]);
const [allTasks, setAllTasks] = useState<Task[]>([]); const [steps, setSteps] = useState<Step[]>(task.steps || []);
const [comments, setComments] = useState<Message[]>([]);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [editingDesc, setEditingDesc] = useState(false); const [editingDesc, setEditingDesc] = useState(false);
const [editingTitle, setEditingTitle] = useState(false); const [editingTitle, setEditingTitle] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false);
const [newStep, setNewStep] = useState("");
const [newComment, setNewComment] = useState("");
useEffect(() => { useEffect(() => {
getAgents().then(setAgents).catch(() => {}); getMembers().then(setMembers).catch(() => {});
getTasks(projectId).then((t) => setAllTasks(t.filter((x) => x.id !== task.id))).catch(() => {}); getSteps(task.id).then(setSteps).catch(() => {});
}, []); getMessages({ task_id: task.id }).then(setComments).catch(() => {});
}, [task.id]);
const save = async (patch: Partial<Task>) => { const save = async (patch: Partial<Task>) => {
setSaving(true); setSaving(true);
@ -67,12 +81,42 @@ export default function TaskModal({ task, projectId, onClose, onUpdated, onDelet
} }
}; };
const currentStatus = STATUSES.find((s) => s.key === status); const handleAddStep = async () => {
const currentPriority = PRIORITIES.find((p) => p.key === priority); if (!newStep.trim()) return;
try {
const step = await createStep(task.id, newStep.trim());
setSteps((prev) => [...prev, step]);
setNewStep("");
} catch (e) {
console.error(e);
}
};
const handleToggleStep = async (step: Step) => {
try {
const updated = await updateStep(task.id, step.id, { done: !step.done });
setSteps((prev) => prev.map((s) => (s.id === step.id ? updated : s)));
} catch (e) {
console.error(e);
}
};
const handleAddComment = async () => {
if (!newComment.trim()) return;
try {
const msg = await sendMessage({ task_id: task.id, content: newComment.trim() });
setComments((prev) => [...prev, msg]);
setNewComment("");
} catch (e) {
console.error(e);
}
};
return ( return (
<div className="fixed inset-0 bg-black/60 flex items-start justify-center z-50 overflow-y-auto py-8 px-4" <div
onClick={onClose}> className="fixed inset-0 bg-black/60 flex items-start justify-center z-50 overflow-y-auto py-8 px-4"
onClick={onClose}
>
<div <div
className="bg-[var(--card)] border border-[var(--border)] rounded-xl w-full max-w-2xl" className="bg-[var(--card)] border border-[var(--border)] rounded-xl w-full max-w-2xl"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
@ -92,8 +136,7 @@ export default function TaskModal({ task, projectId, onClose, onUpdated, onDelet
if (e.key === "Enter") (e.target as HTMLInputElement).blur(); if (e.key === "Enter") (e.target as HTMLInputElement).blur();
if (e.key === "Escape") { setTitle(task.title); setEditingTitle(false); } if (e.key === "Escape") { setTitle(task.title); setEditingTitle(false); }
}} }}
className="w-full text-xl font-bold bg-transparent border-b border-[var(--accent)] className="w-full text-xl font-bold bg-transparent border-b border-[var(--accent)] outline-none pb-1"
outline-none pb-1"
/> />
) : ( ) : (
<h2 <h2
@ -103,41 +146,35 @@ export default function TaskModal({ task, projectId, onClose, onUpdated, onDelet
{title} {title}
</h2> </h2>
)} )}
<div className="text-xs text-[var(--muted)] mt-1">{task.key || task.id.slice(0, 8)}</div> <div className="text-xs text-[var(--muted)] mt-1">#{task.number}</div>
</div> </div>
<div className="flex flex-col md:flex-row"> <div className="flex flex-col md:flex-row">
{/* Left: Description */} {/* Left: Description + Steps + Comments */}
<div className="flex-1 p-4 md:p-6 border-b md:border-b-0 md:border-r border-[var(--border)]"> <div className="flex-1 p-4 md:p-6 border-b md:border-b-0 md:border-r border-[var(--border)] space-y-6">
{/* Description */}
<div>
<div className="text-sm text-[var(--muted)] mb-2 flex items-center justify-between"> <div className="text-sm text-[var(--muted)] mb-2 flex items-center justify-between">
<span>Описание</span> <span>Описание</span>
{!editingDesc && ( {!editingDesc && (
<button <button onClick={() => setEditingDesc(true)} className="text-xs text-[var(--accent)] hover:underline">
onClick={() => setEditingDesc(true)}
className="text-xs text-[var(--accent)] hover:underline"
>
Редактировать Редактировать
</button> </button>
)} )}
</div> </div>
{editingDesc ? ( {editingDesc ? (
<div> <div>
<textarea <textarea
autoFocus autoFocus
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
className="w-full bg-[var(--bg)] border border-[var(--border)] rounded-lg px-3 py-2 className="w-full bg-[var(--bg)] border border-[var(--border)] rounded-lg px-3 py-2 text-sm outline-none focus:border-[var(--accent)] resize-y min-h-[120px]"
text-sm outline-none focus:border-[var(--accent)] resize-y min-h-[120px]"
rows={6} rows={6}
placeholder="Описание задачи (Markdown)..." placeholder="Описание задачи (Markdown)..."
/> />
<div className="flex gap-2 mt-2"> <div className="flex gap-2 mt-2">
<button <button
onClick={() => { onClick={() => { setEditingDesc(false); save({ description: description.trim() || undefined }); }}
setEditingDesc(false);
save({ description: description.trim() || null });
}}
className="px-3 py-1 bg-[var(--accent)] text-white rounded text-xs" className="px-3 py-1 bg-[var(--accent)] text-white rounded text-xs"
> >
Сохранить Сохранить
@ -155,12 +192,66 @@ export default function TaskModal({ task, projectId, onClose, onUpdated, onDelet
) : ( ) : (
<div className="text-sm text-[var(--muted)] italic">Нет описания</div> <div className="text-sm text-[var(--muted)] italic">Нет описания</div>
)} )}
</div>
{/* Dependencies placeholder */} {/* Steps */}
<div className="mt-6"> <div>
<div className="text-sm text-[var(--muted)] mb-2">Зависимости</div> <div className="text-sm text-[var(--muted)] mb-2">
<div className="text-sm text-[var(--muted)] italic"> Этапы {steps.length > 0 && `(${steps.filter((s) => s.done).length}/${steps.length})`}
Пока нет зависимостей </div>
<div className="space-y-1">
{steps.map((step) => (
<label key={step.id} className="flex items-center gap-2 text-sm cursor-pointer group">
<input
type="checkbox"
checked={step.done}
onChange={() => handleToggleStep(step)}
className="accent-[var(--accent)]"
/>
<span className={step.done ? "line-through text-[var(--muted)]" : ""}>{step.title}</span>
</label>
))}
</div>
<div className="flex gap-2 mt-2">
<input
value={newStep}
onChange={(e) => setNewStep(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleAddStep()}
placeholder="Новый этап..."
className="flex-1 bg-[var(--bg)] border border-[var(--border)] rounded px-2 py-1 text-xs outline-none focus:border-[var(--accent)]"
/>
<button onClick={handleAddStep} className="text-xs text-[var(--accent)]">+</button>
</div>
</div>
{/* Comments */}
<div>
<div className="text-sm text-[var(--muted)] mb-2">Комментарии</div>
<div className="space-y-3 max-h-[300px] overflow-y-auto">
{comments.map((msg) => (
<div key={msg.id} className="text-sm">
<div className="flex items-center gap-1 text-xs text-[var(--muted)] mb-0.5">
<span>{AUTHOR_ICON[msg.author_type] || "👤"}</span>
<span className="font-medium">{msg.author_slug}</span>
<span>·</span>
<span>{new Date(msg.created_at).toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit" })}</span>
</div>
<div className="ml-5 whitespace-pre-wrap">{msg.content}</div>
</div>
))}
{comments.length === 0 && (
<div className="text-sm text-[var(--muted)] italic">Нет комментариев</div>
)}
</div>
<div className="flex gap-2 mt-3">
<input
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleAddComment()}
placeholder="Написать комментарий..."
className="flex-1 bg-[var(--bg)] border border-[var(--border)] rounded px-2 py-1.5 text-sm outline-none focus:border-[var(--accent)]"
/>
<button onClick={handleAddComment} className="text-xs text-[var(--accent)] px-2"></button>
</div> </div>
</div> </div>
</div> </div>
@ -174,15 +265,9 @@ export default function TaskModal({ task, projectId, onClose, onUpdated, onDelet
{STATUSES.map((s) => ( {STATUSES.map((s) => (
<button <button
key={s.key} key={s.key}
onClick={() => { onClick={() => { setStatus(s.key); save({ status: s.key }); }}
setStatus(s.key);
save({ status: s.key });
}}
className={`flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors className={`flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors
${status === s.key ${status === s.key ? "bg-white/10 text-[var(--fg)]" : "text-[var(--muted)] hover:bg-white/5"}`}
? "bg-white/10 text-[var(--fg)]"
: "text-[var(--muted)] hover:bg-white/5"
}`}
> >
<span className="w-2 h-2 rounded-full" style={{ background: s.color }} /> <span className="w-2 h-2 rounded-full" style={{ background: s.color }} />
{s.label} {s.label}
@ -198,15 +283,9 @@ export default function TaskModal({ task, projectId, onClose, onUpdated, onDelet
{PRIORITIES.map((p) => ( {PRIORITIES.map((p) => (
<button <button
key={p.key} key={p.key}
onClick={() => { onClick={() => { setPriority(p.key); save({ priority: p.key }); }}
setPriority(p.key);
save({ priority: p.key });
}}
className={`flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors className={`flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors
${priority === p.key ${priority === p.key ? "bg-white/10 text-[var(--fg)]" : "text-[var(--muted)] hover:bg-white/5"}`}
? "bg-white/10 text-[var(--fg)]"
: "text-[var(--muted)] hover:bg-white/5"
}`}
> >
<span className="w-2 h-2 rounded-full" style={{ background: p.color }} /> <span className="w-2 h-2 rounded-full" style={{ background: p.color }} />
{p.label} {p.label}
@ -219,61 +298,47 @@ export default function TaskModal({ task, projectId, onClose, onUpdated, onDelet
<div> <div>
<div className="text-xs text-[var(--muted)] mb-1">Исполнитель</div> <div className="text-xs text-[var(--muted)] mb-1">Исполнитель</div>
<select <select
value={assignedAgentId} value={assigneeSlug}
onChange={(e) => { onChange={(e) => {
setAssignedAgentId(e.target.value); setAssigneeSlug(e.target.value);
save({ assigned_agent_id: e.target.value || null }); save({ assignee_slug: e.target.value || undefined } as any);
}} }}
className="w-full bg-[var(--bg)] border border-[var(--border)] rounded px-2 py-1.5 className="w-full bg-[var(--bg)] border border-[var(--border)] rounded px-2 py-1.5 text-sm outline-none focus:border-[var(--accent)]"
text-sm outline-none focus:border-[var(--accent)]"
> >
<option value="">Не назначен</option> <option value="">Не назначен</option>
{agents.map((a) => ( {members.map((m) => (
<option key={a.id} value={a.id}>{a.name}</option> <option key={m.slug} value={m.slug}>
{m.type === "agent" ? "🤖 " : "👤 "}{m.name}
</option>
))} ))}
</select> </select>
</div> </div>
{/* PR */} {/* Labels */}
{task.labels && task.labels.length > 0 && (
<div> <div>
<label className="flex items-center gap-2 text-sm"> <div className="text-xs text-[var(--muted)] mb-1">Лейблы</div>
<input <div className="flex flex-wrap gap-1">
type="checkbox" {task.labels.map((l) => (
checked={task.requires_pr} <span key={l} className="text-xs px-2 py-0.5 rounded bg-white/10">{l}</span>
onChange={(e) => save({ requires_pr: e.target.checked })} ))}
className="accent-[var(--accent)]"
/>
Требует PR
</label>
{task.pr_url && (
<a href={task.pr_url} target="_blank" className="text-xs text-[var(--accent)] hover:underline mt-1 block">
{task.pr_url}
</a>
)}
</div> </div>
</div>
)}
{/* Delete */} {/* Delete */}
<div className="pt-4 border-t border-[var(--border)]"> <div className="pt-4 border-t border-[var(--border)]">
{confirmDelete ? ( {confirmDelete ? (
<div className="flex gap-2"> <div className="flex gap-2">
<button <button onClick={handleDelete} className="px-3 py-1.5 bg-red-500/20 text-red-400 rounded text-xs hover:bg-red-500/30">
onClick={handleDelete}
className="px-3 py-1.5 bg-red-500/20 text-red-400 rounded text-xs hover:bg-red-500/30"
>
Да, удалить Да, удалить
</button> </button>
<button <button onClick={() => setConfirmDelete(false)} className="px-3 py-1.5 text-[var(--muted)] text-xs">
onClick={() => setConfirmDelete(false)}
className="px-3 py-1.5 text-[var(--muted)] text-xs"
>
Отмена Отмена
</button> </button>
</div> </div>
) : ( ) : (
<button <button onClick={() => setConfirmDelete(true)} className="text-xs text-red-400/60 hover:text-red-400">
onClick={() => setConfirmDelete(true)}
className="text-xs text-red-400/60 hover:text-red-400"
>
Удалить задачу Удалить задачу
</button> </button>
)} )}

View File

@ -1,126 +1,220 @@
// API proxied through nginx on same domain /**
const API_BASE = process.env.NEXT_PUBLIC_API_URL || ""; * API client for Team Board BFF.
*/
function getAuthHeaders(): Record<string, string> { const API_BASE = process.env.NEXT_PUBLIC_API_URL || "https://team.uix.su";
if (typeof window !== "undefined") {
const token = localStorage.getItem("tb_token"); function getToken(): string | null {
if (token) return { Authorization: `Bearer ${token}` }; if (typeof window === "undefined") return null;
} return localStorage.getItem("token");
return {};
} }
async function request<T>(path: string, opts?: RequestInit): Promise<T> { async function request<T = any>(path: string, options: RequestInit = {}): Promise<T> {
const url = `${API_BASE}${path}`; const token = getToken();
console.log(`[API] ${opts?.method || "GET"} ${url}`); const headers: Record<string, string> = {
"Content-Type": "application/json",
const res = await fetch(url, { ...(options.headers as Record<string, string>),
headers: { "Content-Type": "application/json", ...getAuthHeaders(), ...opts?.headers }, };
...opts, if (token) headers["Authorization"] = `Bearer ${token}`;
});
const res = await fetch(`${API_BASE}${path}`, { ...options, headers });
if (!res.ok) { if (!res.ok) {
const body = await res.text(); const err = await res.json().catch(() => ({ error: res.statusText }));
console.error(`[API] ${res.status} ${url}`, body); throw new Error(err.error || `HTTP ${res.status}`);
throw new Error(`API ${res.status}: ${body}`); }
if (res.status === 204) return {} as T;
return res.json();
} }
if (res.status === 204) return undefined as T; // --- Types ---
const data = await res.json();
console.log(`[API] ${res.status} ${url}`, data); export interface AgentConfig {
return data; capabilities: string[];
chat_listen: string;
task_listen: string;
prompt: string | null;
model: string | null;
}
export interface Member {
id: string;
name: string;
slug: string;
type: "human" | "agent";
role: string;
status: string;
avatar_url: string | null;
agent_config: AgentConfig | null;
}
export interface MemberCreateResponse extends Member {
token?: string;
} }
// Types
export interface Project { export interface Project {
id: string; id: string;
name: string; name: string;
slug: string; slug: string;
description: string | null; description: string | null;
key_prefix: string; repo_urls: string[];
status: string;
task_counter: number; task_counter: number;
git_repo: string | null; }
export interface Step {
id: string;
title: string;
done: boolean;
position: number;
} }
export interface Task { export interface Task {
id: string; id: string;
project_id: string; project_id: string;
parent_id: string | null; parent_id: string | null;
number: number;
title: string; title: string;
description: string | null; description: string | null;
type: string;
status: string; status: string;
priority: string; priority: string;
requires_pr: boolean; labels: string[];
pr_url: string | null; assignee_slug: string | null;
assigned_agent_id: string | null; reviewer_slug: string | null;
number: number; watchers: string[];
key: string | null; depends_on: string[];
position: number; position: number;
time_spent: number;
steps: Step[];
} }
export interface Agent { export interface Attachment {
id: string; id: string;
name: string; filename: string;
slug: string; mime_type: string | null;
capabilities: string[]; size: number;
subscription_mode: string;
max_concurrent: number;
status: string;
} }
export interface Label { export interface Message {
id: string; id: string;
name: string; chat_id: string | null;
color: string | null;
}
// Projects
export const getProjects = () => request<Project[]>("/api/v1/projects/");
export const createProject = (data: Partial<Project>) =>
request<Project>("/api/v1/projects/", { method: "POST", body: JSON.stringify(data) });
// Tasks
export const getTasks = (projectId?: string) => {
const q = projectId ? `?project_id=${projectId}` : "";
return request<Task[]>(`/api/v1/tasks/${q}`);
};
export const createTask = (data: Partial<Task>) =>
request<Task>("/api/v1/tasks/", { method: "POST", body: JSON.stringify(data) });
export const updateTask = (id: string, data: Partial<Task>) =>
request<Task>(`/api/v1/tasks/${id}`, { method: "PATCH", body: JSON.stringify(data) });
export const deleteTask = (id: string) =>
request<void>(`/api/v1/tasks/${id}`, { method: "DELETE" });
// Agents
export const getAgents = () => request<Agent[]>("/api/v1/agents/");
// Chats
export interface Chat {
id: string;
project_id: string | null;
task_id: string | null; task_id: string | null;
kind: string; parent_id: string | null;
} author_type: string;
author_slug: string;
export interface ChatMessage {
id: string;
chat_id: string;
sender_type: string;
sender_id: string | null;
sender_name: string | null;
content: string; content: string;
mentions: string[];
voice_url: string | null;
attachments: Attachment[];
created_at: string; created_at: string;
} }
export const getLobbyChat = () => request<Chat>("/api/v1/chats/lobby"); // --- Auth ---
export const getProjectChat = (projectId: string) =>
request<Chat>(`/api/v1/chats/project/${projectId}`);
export const getChatMessages = (chatId: string, limit = 50) =>
request<ChatMessage[]>(`/api/v1/chats/${chatId}/messages?limit=${limit}`);
export const sendChatMessage = (chatId: string, content: string) =>
request<ChatMessage>(`/api/v1/chats/${chatId}/messages`, {
method: "POST",
body: JSON.stringify({ content }),
});
// Labels export async function login(username: string, password: string) {
export const getLabels = () => request<Label[]>("/api/v1/labels/"); return request("/api/auth/login", {
method: "POST",
body: JSON.stringify({ username, password }),
});
}
// --- Projects ---
export async function getProjects(): Promise<Project[]> {
return request("/api/v1/projects");
}
export async function getProject(slug: string): Promise<Project> {
return request(`/api/v1/projects/${slug}`);
}
export async function createProject(data: { name: string; slug: string; description?: string }): Promise<Project> {
return request("/api/v1/projects", { method: "POST", body: JSON.stringify(data) });
}
// --- 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(projectSlug: string, data: Partial<Task>): Promise<Task> {
return request(`/api/v1/tasks?project_slug=${projectSlug}`, {
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" });
}
// --- 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 }): 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));
return request(`/api/v1/messages?${qs}`);
}
export async function sendMessage(data: {
chat_id?: string;
task_id?: string;
content: string;
mentions?: 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(slug: string): Promise<Member> {
return request(`/api/v1/members/${slug}`);
}
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) });
}

View File

@ -1,54 +1,59 @@
/** /**
* WebSocket client for Team Board real-time features. * WebSocket client for Team Board.
* Connects to BFF WebSocket which proxies to Tracker. * Connects through BFF proxy Tracker.
*/ */
type WsCallback = (data: any) => void; type MessageHandler = (data: any) => void;
class WsClient { class WSClient {
private ws: WebSocket | null = null; private ws: WebSocket | null = null;
private listeners: Map<string, Set<WsCallback>> = new Map(); private handlers: Map<string, Set<MessageHandler>> = new Map();
private reconnectTimer: ReturnType<typeof setTimeout> | null = null; private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private subscribedRooms: Set<string> = new Set(); private _lobbyId: string | null = null;
private _projects: Array<{ id: string; slug: string; name: string }> = [];
private _online: string[] = [];
get lobbyId() { return this._lobbyId; }
get projects() { return this._projects; }
get online() { return this._online; }
get connected() { return this.ws?.readyState === WebSocket.OPEN; }
connect() { connect() {
if (this.ws?.readyState === WebSocket.OPEN) return; const token = localStorage.getItem("token");
const token = typeof window !== "undefined" ? localStorage.getItem("tb_token") : null;
if (!token) return; if (!token) return;
const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; const wsBase = process.env.NEXT_PUBLIC_WS_URL || "wss://team.uix.su";
const url = `${proto}//${window.location.host}/ws?token=${token}`; this.ws = new WebSocket(`${wsBase}/ws?token=${token}`);
this.ws = new WebSocket(url); this.ws.onmessage = (event) => {
this.ws.onopen = () => {
console.log("[WS] connected");
// Re-subscribe to rooms
for (const room of this.subscribedRooms) {
this.send("chat.subscribe", { chat_id: room });
}
};
this.ws.onmessage = (e) => {
try { try {
const msg = JSON.parse(e.data); const msg = JSON.parse(event.data);
const event = msg.event; const type = msg.type;
if (event) {
const cbs = this.listeners.get(event); // Handle auth.ok
if (cbs) cbs.forEach((cb) => cb(msg)); if (type === "auth.ok") {
this._lobbyId = msg.data?.lobby_chat_id || null;
this._projects = msg.data?.projects || [];
this._online = msg.data?.online || [];
}
// Dispatch to handlers
const fns = this.handlers.get(type);
if (fns) fns.forEach((fn) => fn(msg.data || msg));
// Also dispatch to wildcard
const all = this.handlers.get("*");
if (all) all.forEach((fn) => fn(msg));
} catch (e) {
console.error("WS parse error:", e);
} }
} catch {}
}; };
this.ws.onclose = () => { this.ws.onclose = () => {
console.log("[WS] disconnected, reconnecting in 3s...");
this.reconnectTimer = setTimeout(() => this.connect(), 3000); this.reconnectTimer = setTimeout(() => this.connect(), 3000);
}; };
this.ws.onerror = () => { this.ws.onerror = () => {};
this.ws?.close();
};
} }
disconnect() { disconnect() {
@ -57,27 +62,38 @@ class WsClient {
this.ws = null; this.ws = null;
} }
send(event: string, data: Record<string, any> = {}) { on(type: string, handler: MessageHandler) {
if (!this.handlers.has(type)) this.handlers.set(type, new Set());
this.handlers.get(type)!.add(handler);
return () => this.handlers.get(type)?.delete(handler);
}
send(data: any) {
if (this.ws?.readyState === WebSocket.OPEN) { if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ event, ...data })); this.ws.send(JSON.stringify(data));
} }
} }
subscribeChat(chatId: string) { // Convenience methods
this.subscribedRooms.add(chatId); subscribeProject(projectId: string) {
this.send("chat.subscribe", { chat_id: chatId }); this.send({ type: "project.subscribe", project_id: projectId });
} }
unsubscribeChat(chatId: string) { unsubscribeProject(projectId: string) {
this.subscribedRooms.delete(chatId); this.send({ type: "project.unsubscribe", project_id: projectId });
this.send("chat.unsubscribe", { chat_id: chatId });
} }
on(event: string, cb: WsCallback) { sendChat(chatId: string, content: string, mentions: string[] = []) {
if (!this.listeners.has(event)) this.listeners.set(event, new Set()); this.send({ type: "chat.send", chat_id: chatId, content, mentions });
this.listeners.get(event)!.add(cb); }
return () => this.listeners.get(event)?.delete(cb);
sendTaskComment(taskId: string, content: string, mentions: string[] = []) {
this.send({ type: "chat.send", task_id: taskId, content, mentions });
}
heartbeat(status: string = "online") {
this.send({ type: "heartbeat", status });
} }
} }
export const wsClient = new WsClient(); export const wsClient = new WSClient();