commit a76ec74338a82ff03242f6129992a3c59440546b Author: Egutierrez Date: Mon May 18 18:46:09 2026 +0200 feat: initial scaffold kanban_cpp v0.1.0 C++ ImGui kanban for steering LLM agents. Six panels (Board, Calendar, Dashboard, Agent runs, Worktrees, DoD inspector) wired to registry functions http_request, kpi_card, sparkline, agent_runs_timeline, dod_evidence_panel. Backend Go on :8403 (independent operations.db from kanban_web). diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a5d1840 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +build/ +*.exe +*.log +backend/operations.db +backend/operations.db-shm +backend/operations.db-wal +backend/kanban_cpp_backend +backend/dist/* +!backend/dist/.gitkeep +local_files/ +imgui.ini +app_settings.ini diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..d406812 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,24 @@ +add_imgui_app(kanban_cpp + main.cpp + data.cpp + panel_board.cpp + panel_calendar.cpp + panel_dashboard.cpp + panel_agent_runs.cpp + panel_worktrees.cpp + panel_dod.cpp + # Registry functions consumed (see app.md::uses_functions) + ${CMAKE_SOURCE_DIR}/functions/core/http_request.cpp + ${CMAKE_SOURCE_DIR}/functions/viz/kpi_card.cpp + ${CMAKE_SOURCE_DIR}/functions/viz/sparkline.cpp + ${CMAKE_SOURCE_DIR}/functions/viz/agent_runs_timeline.cpp + ${CMAKE_SOURCE_DIR}/functions/viz/agent_runs_timeline_helpers.cpp + ${CMAKE_SOURCE_DIR}/functions/viz/dod_evidence_panel.cpp + ${CMAKE_SOURCE_DIR}/functions/viz/dod_evidence_panel_helpers.cpp +) +target_include_directories(kanban_cpp PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) + +if(WIN32) + target_link_libraries(kanban_cpp PRIVATE ws2_32) + set_target_properties(kanban_cpp PROPERTIES WIN32_EXECUTABLE TRUE) +endif() diff --git a/app.md b/app.md new file mode 100644 index 0000000..ef80e76 --- /dev/null +++ b/app.md @@ -0,0 +1,77 @@ +--- +name: kanban_cpp +lang: cpp +domain: tools +version: 0.1.0 +description: "Clon C++ ImGui de kanban_web — tablero pensado para conducir agentes LLM con DoD evidence" +tags: [kanban, cpp, agents, imgui] +icon: + phosphor: "columns" + accent: "#a855f7" +uses_functions: + - http_request_cpp_core + - dod_evidence_panel_cpp_viz + - agent_runs_timeline_cpp_viz + - kpi_card_cpp_viz + - sparkline_cpp_viz +uses_types: [] +framework: "imgui" +entry_point: "main.cpp" +dir_path: "apps/kanban_cpp" +repo_url: "https://gitea.organic-machine.com/dataforge/kanban_cpp" +e2e_checks: + - id: build + cmd: "cmake --build cpp/build/linux --target kanban_cpp -j" + timeout_s: 300 + - id: self_test + cmd: "./cpp/build/linux/apps/kanban_cpp/kanban_cpp --self-test" + timeout_s: 30 + - id: backend_build + cmd: "cd apps/kanban_cpp/backend && CGO_ENABLED=1 go build -tags fts5 -o kanban_cpp_backend ." + timeout_s: 180 +--- + +# kanban_cpp + +Clon C++ ImGui de kanban_web — tablero pensado para conducir agentes LLM con DoD evidence. + +Backend Go propio en `backend/` (puerto 8403 por defecto) con `operations.db` independiente del kanban_web original. NO sincroniza datos con `apps/kanban` a proposito. + +## Panels + +| Panel | Funcion del registry | Notas | +|---|---|---| +| Board | inline | columnas + cards, drag con ImGui::IsItemActive | +| Calendar | inline | vista mensual estatica (MVP) | +| Dashboard | `kpi_card_cpp_viz` + `sparkline_cpp_viz` | KPIs (total, by_status, by_priority) | +| Agent runs | `agent_runs_timeline_cpp_viz` | populated por HTTP poll a agent_runner_api:8486 | +| Worktrees | inline | `git worktree list --porcelain` via popen | +| DoD inspector | `dod_evidence_panel_cpp_viz` | inspecciona DoD items + evidencias | + +## Build + +```bash +# Backend +cd apps/kanban_cpp/backend && CGO_ENABLED=1 go build -tags fts5 -o kanban_cpp_backend . +./kanban_cpp_backend --port 8403 --db operations.db + +# Frontend ImGui +cd cpp && cmake -B build/linux && cmake --build build/linux --target kanban_cpp -j +./build/linux/apps/kanban_cpp/kanban_cpp +``` + +## Cuando usarla + +Cuando quieras un kanban dedicado a conducir agentes LLM (arrastrar card a `Doing (agent)` → arranca workflow) sin abrir browser. Para uso humano puro, `kanban_web` (Mantine) sigue siendo mejor. + +## Gotchas + +- 2 services + 2 sqlite locks: kanban_web :8095/8401 y kanban_cpp :8403 NUNCA comparten `operations.db`. +- `agent_runner_api` (puerto 8486) puede no estar corriendo — el panel `Agent runs` muestra `connection_status="disconnected"` en ese caso. No bloquea el resto de paneles. +- Calendar es MVP estatico — TODO: integrarlo con cards filtradas por `due_date`. +- Dashboard usa datos sinteticos hasta wire-up del backend stats endpoint (TODO). +- Auth: cada app tiene sus propios usuarios. NO compartir cookies entre kanban_web y kanban_cpp. + +## Capability growth log + +(v0.1.0 baseline — sin crecimiento aun) diff --git a/appicon.ico b/appicon.ico new file mode 100644 index 0000000..936e6e6 Binary files /dev/null and b/appicon.ico differ diff --git a/backend/auth.go b/backend/auth.go new file mode 100644 index 0000000..88704c6 --- /dev/null +++ b/backend/auth.go @@ -0,0 +1,156 @@ +package main + +import ( + "errors" + "net/http" + "time" + + "fn-registry/functions/infra" +) + +const ( + cookieName = "kanban_session" + sessionTTL = 7 * 24 * time.Hour +) + +type ctxKey string + +const userCtxKey ctxKey = "kanban_user_id" + +func setSessionCookie(w http.ResponseWriter, token string, expiresAt int64) { + infra.SessionCookieSet(w, cookieName, token, expiresAt) +} + +func clearSessionCookie(w http.ResponseWriter) { + infra.SessionCookieClear(w, cookieName) +} + +func tokenFromRequest(r *http.Request) string { + return infra.SessionTokenExtract(r, cookieName) +} + +// POST /api/auth/register {username, password, display_name?} +func handleRegister(db *DB, flags *FeatureFlags) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !flags.Enabled("registration-enabled") { + infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusForbidden, Code: "registration_disabled", Message: "user registration is disabled on this instance"}) + return + } + var body struct { + Username string `json:"username"` + Password string `json:"password"` + DisplayName string `json:"display_name"` + } + if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil { + badRequest(w, err.Error()) + return + } + u, err := db.CreateUser(body.Username, body.Password, body.DisplayName) + if err != nil { + if errors.Is(err, errUserAlreadyExists) { + infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusConflict, Code: "user_exists", Message: err.Error()}) + return + } + badRequest(w, err.Error()) + return + } + infra.HTTPJSONResponse(w, http.StatusCreated, u) + } +} + +// POST /api/auth/login {username, password} +func handleLogin(db *DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var body struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil { + badRequest(w, err.Error()) + return + } + u, err := db.Authenticate(body.Username, body.Password) + if err != nil { + infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusUnauthorized, Code: "invalid_credentials", Message: "invalid username or password"}) + return + } + sess, err := infra.SessionCreate(db.conn, u.ID, sessionTTL, map[string]any{"username": u.Username}) + if err != nil { + serverError(w, err) + return + } + setSessionCookie(w, sess.Token, sess.ExpiresAt) + infra.HTTPJSONResponse(w, http.StatusOK, u) + } +} + +// POST /api/auth/logout +func handleLogout(db *DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + token := tokenFromRequest(r) + if token != "" { + _ = db.DeleteSessionByToken(token) + } + clearSessionCookie(w) + w.WriteHeader(http.StatusNoContent) + } +} + +// GET /api/me +func handleMe(db *DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + uid, ok := infra.UserIDFromContext(r.Context(), userCtxKey) + if !ok { + infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "no session"}) + return + } + u, err := db.GetUserByID(uid) + if err != nil { + serverError(w, err) + return + } + infra.HTTPJSONResponse(w, http.StatusOK, u) + } +} + +// PATCH /api/me { color? } +func handlePatchMe(db *DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + uid, ok := infra.UserIDFromContext(r.Context(), userCtxKey) + if !ok { + infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "no session"}) + return + } + var body struct { + Color *string `json:"color"` + } + if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil { + badRequest(w, err.Error()) + return + } + if body.Color != nil { + if err := db.UpdateUserColor(uid, *body.Color); err != nil { + serverError(w, err) + return + } + } + u, err := db.GetUserByID(uid) + if err != nil { + serverError(w, err) + return + } + infra.HTTPJSONResponse(w, http.StatusOK, u) + } +} + +// GET /api/users +func handleListUsers(db *DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + users, err := db.ListUsers() + if err != nil { + serverError(w, err) + return + } + infra.HTTPJSONResponse(w, http.StatusOK, users) + } +} diff --git a/backend/chat.go b/backend/chat.go new file mode 100644 index 0000000..cecafb1 --- /dev/null +++ b/backend/chat.go @@ -0,0 +1,269 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "fn-registry/functions/core" + "fn-registry/functions/infra" + "nhooyr.io/websocket" +) + +const chatSystemPrompt = `Asistente del tablero kanban. Modifica el tablero llamando a tools MCP cuando el usuario pida cambios. Responde texto en markdown cuando solo informe. + +Tools (MCP server "kanban"): +- Lectura: list_board, find_cards, card_history, list_users +- Columnas: create_column, update_column, delete_column, reorder_columns +- Tarjetas: create_card, update_card, delete_card, move_card, assign_card + +El estado actual del tablero viene en al final del mensaje. Usa esos IDs directamente — NO llames list_board si ya tienes lo que necesitas. NUNCA inventes IDs. + +Cuando termines, responde texto natural sin mas llamadas — eso cierra la conversacion.` + +const claudeTimeout = 300 * time.Second + +func claudeBinary() string { + if b := os.Getenv("KANBAN_CLAUDE_BIN"); b != "" { + return b + } + return "claude" +} + +func claudeModel() string { + if m := os.Getenv("KANBAN_CLAUDE_MODEL"); m != "" { + return m + } + return "claude-haiku-4-5-20251001" +} + +type chatMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type chatRequest struct { + Messages []chatMessage `json:"messages"` +} + +// wsEvent is the envelope sent to the browser. Type discriminates the payload. +type wsEvent struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + ToolID string `json:"tool_id,omitempty"` + Tool string `json:"tool,omitempty"` + Input json.RawMessage `json:"input,omitempty"` + Result string `json:"result,omitempty"` + IsError bool `json:"is_error,omitempty"` + BoardChanged bool `json:"board_changed,omitempty"` + Error string `json:"error,omitempty"` +} + +// handleChatWS upgrades the request to WebSocket and streams claude events. +// +// Wire protocol: +// client → server (one message): { "messages": [{role, content}, ...] } +// server → client (many): wsEvent ndjson-style messages +// types: "delta" (assistant text), "tool_use", "tool_result", "result", "error" +// server closes connection at end. +func handleChatWS(db *DB, workdir string, logger *ChatLogger, internalToken string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + conn, err := infra.WSUpgrader(w, r, []string{"*"}) + if err != nil { + return + } + defer conn.Close(websocket.StatusInternalError, "internal") + + ctx, cancel := context.WithTimeout(r.Context(), claudeTimeout) + defer cancel() + + // Read the initial chat request. + _, raw, err := conn.Read(ctx) + if err != nil { + return + } + var req chatRequest + if err := json.Unmarshal(raw, &req); err != nil { + sendWS(ctx, conn, wsEvent{Type: "error", Error: "invalid chat request: " + err.Error()}) + return + } + if len(req.Messages) == 0 { + sendWS(ctx, conn, wsEvent{Type: "error", Error: "messages required"}) + return + } + + boardChanged, err := streamChat(ctx, conn, db, workdir, internalToken, req.Messages, logger) + if err != nil { + sendWS(ctx, conn, wsEvent{Type: "error", Error: err.Error()}) + return + } + sendWS(ctx, conn, wsEvent{Type: "done", BoardChanged: boardChanged}) + conn.Close(websocket.StatusNormalClosure, "") + } +} + +func streamChat(ctx context.Context, conn *websocket.Conn, db *DB, workdir, token string, msgs []chatMessage, logger *ChatLogger) (bool, error) { + binPath, err := os.Executable() + if err != nil { + return false, fmt.Errorf("locate kanban binary: %w", err) + } + + // Backend URL: trust X-Forwarded or fall back to localhost (kanban listens + // on its main port). The MCP subprocess hits the loopback interface. + backendURL := os.Getenv("KANBAN_PUBLIC_URL") + if backendURL == "" { + port := os.Getenv("KANBAN_LISTEN_PORT") + if port == "" { + port = "8095" + } + backendURL = "http://127.0.0.1:" + port + } + + mcpPath, err := writeMCPConfig(binPath, backendURL, token) + if err != nil { + return false, fmt.Errorf("write mcp config: %w", err) + } + defer os.Remove(mcpPath) + + prompt := flattenMessages(msgs) + if board, err := boardSnapshot(db); err == nil && board != "" { + prompt += "\n\n\n" + board + "\n\n" + } + + stdin := strings.NewReader(prompt) + events, err := core.StreamClaude(ctx, core.ClaudeStreamOpts{ + Bin: claudeBinary(), + Args: []string{ + "--model", claudeModel(), + "--no-session-persistence", + "--mcp-config", mcpPath, + "--strict-mcp-config", + "--system-prompt", chatSystemPrompt, + "--allowedTools", + "mcp__kanban__list_board,mcp__kanban__create_column,mcp__kanban__update_column,mcp__kanban__rename_column,mcp__kanban__delete_column,mcp__kanban__reorder_columns,mcp__kanban__create_card,mcp__kanban__update_card,mcp__kanban__delete_card,mcp__kanban__move_card,mcp__kanban__card_history,mcp__kanban__find_cards,mcp__kanban__list_users,mcp__kanban__assign_card", + }, + Stdin: stdin, + Workdir: workdir, + }) + if err != nil { + return false, fmt.Errorf("spawn claude: %w", err) + } + + boardChanged := false + for ev := range events { + switch ev.Type { + case core.ClaudeEventTextDelta: + sendWS(ctx, conn, wsEvent{Type: "delta", Text: ev.Text}) + case core.ClaudeEventToolUse: + toolName := stripMCPPrefix(ev.ToolName) + sendWS(ctx, conn, wsEvent{ + Type: "tool_use", + ToolID: ev.ToolUseID, + Tool: toolName, + Input: ev.ToolInput, + }) + if toolMutates(toolName) { + boardChanged = true + } + case core.ClaudeEventToolResult: + sendWS(ctx, conn, wsEvent{ + Type: "tool_result", + ToolID: ev.ToolResultID, + Result: ev.ToolResultContent, + IsError: ev.ToolResultIsError, + }) + case core.ClaudeEventResult: + sendWS(ctx, conn, wsEvent{ + Type: "result", + Text: ev.Result, + IsError: ev.IsError, + }) + case core.ClaudeEventError: + sendWS(ctx, conn, wsEvent{Type: "error", Error: ev.Error}) + } + } + return boardChanged, nil +} + +// stripMCPPrefix removes the "mcp____" prefix added by claude when +// tools come from an MCP server, leaving the bare tool name. +func stripMCPPrefix(name string) string { + const pre = "mcp__kanban__" + if strings.HasPrefix(name, pre) { + return name[len(pre):] + } + return name +} + +func sendWS(ctx context.Context, conn *websocket.Conn, ev wsEvent) { + b, err := json.Marshal(ev) + if err != nil { + return + } + wctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + _ = conn.Write(wctx, websocket.MessageText, b) +} + +// flattenMessages converts chat history into a single prompt for `claude -p`. +func flattenMessages(msgs []chatMessage) string { + var b strings.Builder + for _, m := range msgs { + role := "Usuario" + if m.Role == "assistant" { + role = "Asistente" + } + b.WriteString(role) + b.WriteString(": ") + b.WriteString(m.Content) + b.WriteString("\n\n") + } + return b.String() +} + +// boardSnapshot returns a JSON dump of columns + cards to inject in the +// initial prompt, saving a list_board round-trip. +func boardSnapshot(db *DB) (string, error) { + cols, err := db.ListColumns() + if err != nil { + return "", err + } + cards, err := db.ListCardsWithTime() + if err != nil { + return "", err + } + b, err := json.Marshal(map[string]any{"columns": cols, "cards": cards}) + if err != nil { + return "", err + } + return string(b), nil +} + +// chatWorkdir resolves an absolute working directory for `claude -p`. +func chatWorkdir(dbPath string) string { + abs, err := filepath.Abs(dbPath) + if err != nil { + return "." + } + return filepath.Dir(abs) +} + +// --- Legacy handleChat retained as a thin shim that returns 410 Gone. ------- +// Kept so existing clients see a clear error instead of a 404 while they +// migrate to the WebSocket endpoint. + +func handleChat(_ *DB, _ string, _ *ChatLogger) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + infra.HTTPErrorResponse(w, infra.HTTPError{ + Status: http.StatusGone, + Code: "deprecated", + Message: "POST /api/chat removed; use WebSocket at /api/chat/ws", + }) + } +} + diff --git a/backend/chat_log.go b/backend/chat_log.go new file mode 100644 index 0000000..e3edf36 --- /dev/null +++ b/backend/chat_log.go @@ -0,0 +1,86 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "sync" + "time" +) + +// ChatLogger appends one JSON line per tool invocation to a file. Thread-safe. +// Format per line: {"ts":"...","tool":"...","input":{...},"ok":bool,"error":"...","result_summary":"..."} +type ChatLogger struct { + path string + mu sync.Mutex +} + +func newChatLogger(path string) *ChatLogger { + return &ChatLogger{path: path} +} + +type ChatLogEntry struct { + TS string `json:"ts"` + Tool string `json:"tool"` + Input json.RawMessage `json:"input"` + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + ResultSummary string `json:"result_summary,omitempty"` +} + +func (l *ChatLogger) Log(tool string, input json.RawMessage, res ToolResult) { + if l == nil || l.path == "" { + return + } + entry := ChatLogEntry{ + TS: time.Now().UTC().Format(time.RFC3339Nano), + Tool: tool, + Input: input, + OK: res.OK, + Error: res.Error, + } + if res.OK && res.Result != nil { + entry.ResultSummary = summarizeResult(res.Result) + } + line, err := json.Marshal(entry) + if err != nil { + return + } + l.mu.Lock() + defer l.mu.Unlock() + f, err := os.OpenFile(l.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return + } + defer f.Close() + f.Write(line) + f.Write([]byte("\n")) +} + +// summarizeResult produces a short description of a tool result for the log. +// Keeps the log line compact: full payloads can be reconstructed from operations.db. +func summarizeResult(v any) string { + switch r := v.(type) { + case *Column: + return fmt.Sprintf("column %s name=%q", r.ID, r.Name) + case *Card: + return fmt.Sprintf("card %s title=%q col=%s", r.ID, r.Title, r.ColumnID) + case []Card: + return fmt.Sprintf("%d cards", len(r)) + case []HistoryEntry: + return fmt.Sprintf("%d history entries", len(r)) + case map[string]any: + // list_board shape + cols, _ := r["columns"].([]Column) + cards, _ := r["cards"].([]Card) + return fmt.Sprintf("board: %d cols, %d cards", len(cols), len(cards)) + } + b, err := json.Marshal(v) + if err != nil || len(b) == 0 { + return "" + } + if len(b) > 200 { + return string(b[:200]) + "..." + } + return string(b) +} diff --git a/backend/chat_ws_test.go b/backend/chat_ws_test.go new file mode 100644 index 0000000..153f8e6 --- /dev/null +++ b/backend/chat_ws_test.go @@ -0,0 +1,296 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "nhooyr.io/websocket" +) + +// fakeClaudeScript writes a bash script that emits NDJSON stream-json events +// to stdout and exits 0. Returns the absolute path of the script. +func fakeClaudeScript(t *testing.T, payload string) string { + t.Helper() + if _, err := os.Stat("/bin/bash"); err != nil { + t.Skip("/bin/bash not available") + } + dir := t.TempDir() + path := filepath.Join(dir, "claude") + body := "#!/bin/bash\nset -e\ncat <<'__EOF__'\n" + payload + "\n__EOF__\n" + if err := os.WriteFile(path, []byte(body), 0o755); err != nil { + t.Fatalf("write fake claude: %v", err) + } + return path +} + +// chatWSTestServer wires the WebSocket chat handler in front of a test DB. +func chatWSTestServer(t *testing.T) (*httptest.Server, *DB, string) { + t.Helper() + db := setupTestDB(t) + dir := t.TempDir() + logger := newChatLogger(filepath.Join(dir, "chat.log")) + token := generateInternalToken() + srv := httptest.NewServer(handleChatWS(db, dir, logger, token)) + t.Cleanup(srv.Close) + return srv, db, token +} + +func dialChatWS(t *testing.T, srv *httptest.Server) *websocket.Conn { + t.Helper() + u, _ := url.Parse(srv.URL) + wsURL := "ws://" + u.Host + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + c, _, err := websocket.Dial(ctx, wsURL, nil) + if err != nil { + t.Fatalf("dial %s: %v", wsURL, err) + } + return c +} + +func readWSEvent(t *testing.T, conn *websocket.Conn) wsEvent { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, data, err := conn.Read(ctx) + if err != nil { + t.Fatalf("read: %v", err) + } + var ev wsEvent + if err := json.Unmarshal(data, &ev); err != nil { + t.Fatalf("unmarshal %q: %v", string(data), err) + } + return ev +} + +func sendInitial(t *testing.T, conn *websocket.Conn, msgs []chatMessage) { + t.Helper() + body, _ := json.Marshal(chatRequest{Messages: msgs}) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := conn.Write(ctx, websocket.MessageText, body); err != nil { + t.Fatalf("write: %v", err) + } +} + +// --- WS streaming tests --------------------------------------------------- + +func TestChatWS_StreamsTextDelta(t *testing.T) { + payload := `{"type":"system","subtype":"init","session_id":"s1","model":"test"} +{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Hola "}]}} +{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"mundo"}]}} +{"type":"result","subtype":"success","is_error":false,"result":"Hola mundo","stop_reason":"end_turn"}` + + t.Setenv("KANBAN_CLAUDE_BIN", fakeClaudeScript(t, payload)) + + srv, _, _ := chatWSTestServer(t) + conn := dialChatWS(t, srv) + defer conn.Close(websocket.StatusNormalClosure, "") + + sendInitial(t, conn, []chatMessage{{Role: "user", Content: "saluda"}}) + + var deltas []string + var sawResult, sawDone bool + for i := 0; i < 12 && !sawDone; i++ { + ev := readWSEvent(t, conn) + switch ev.Type { + case "delta": + deltas = append(deltas, ev.Text) + case "result": + sawResult = true + case "done": + sawDone = true + case "error": + t.Fatalf("unexpected error event: %s", ev.Error) + } + } + if !sawDone { + t.Fatalf("never received done event") + } + if !sawResult { + t.Fatalf("never received result event") + } + if got := strings.Join(deltas, ""); got != "Hola mundo" { + t.Fatalf("expected 'Hola mundo' from deltas, got %q", got) + } +} + +func TestChatWS_StreamsToolUseAndResult(t *testing.T) { + payload := `{"type":"system","subtype":"init"} +{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_1","name":"mcp__kanban__create_column","input":{"name":"Backlog"}}]}} +{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"{\"ok\":true,\"result\":{\"id\":\"col_x\"}}","is_error":false}]}} +{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Listo"}]}} +{"type":"result","subtype":"success","is_error":false,"result":"Listo","stop_reason":"end_turn"}` + + t.Setenv("KANBAN_CLAUDE_BIN", fakeClaudeScript(t, payload)) + + srv, _, _ := chatWSTestServer(t) + conn := dialChatWS(t, srv) + defer conn.Close(websocket.StatusNormalClosure, "") + + sendInitial(t, conn, []chatMessage{{Role: "user", Content: "crea Backlog"}}) + + var sawToolUse, sawToolResult, sawDelta, sawDone bool + var doneEv wsEvent + for i := 0; i < 16 && !sawDone; i++ { + ev := readWSEvent(t, conn) + switch ev.Type { + case "tool_use": + sawToolUse = true + if ev.Tool != "create_column" { + t.Errorf("tool name not stripped: %q", ev.Tool) + } + if !strings.Contains(string(ev.Input), "Backlog") { + t.Errorf("input missing Backlog: %s", ev.Input) + } + case "tool_result": + sawToolResult = true + if ev.IsError { + t.Errorf("tool_result is_error true") + } + case "delta": + sawDelta = true + case "done": + sawDone = true + doneEv = ev + case "error": + t.Fatalf("unexpected error: %s", ev.Error) + } + } + if !sawToolUse || !sawToolResult || !sawDelta || !sawDone { + t.Fatalf("missing events: tool_use=%v tool_result=%v delta=%v done=%v", + sawToolUse, sawToolResult, sawDelta, sawDone) + } + if !doneEv.BoardChanged { + t.Errorf("expected board_changed=true (create_column is a mutator)") + } +} + +func TestChatWS_RejectsEmptyMessages(t *testing.T) { + t.Setenv("KANBAN_CLAUDE_BIN", fakeClaudeScript(t, + `{"type":"result","subtype":"success","is_error":false,"result":""}`)) + + srv, _, _ := chatWSTestServer(t) + conn := dialChatWS(t, srv) + defer conn.Close(websocket.StatusNormalClosure, "") + + sendInitial(t, conn, []chatMessage{}) + ev := readWSEvent(t, conn) + if ev.Type != "error" { + t.Fatalf("expected error event, got %+v", ev) + } + if !strings.Contains(ev.Error, "messages required") { + t.Fatalf("unexpected error: %s", ev.Error) + } +} + +func TestChatWS_PropagatesClaudeFailure(t *testing.T) { + dir := t.TempDir() + bin := filepath.Join(dir, "claude") + body := "#!/bin/bash\necho 'broken' >&2\nexit 7\n" + if err := os.WriteFile(bin, []byte(body), 0o755); err != nil { + t.Fatalf("write: %v", err) + } + t.Setenv("KANBAN_CLAUDE_BIN", bin) + + srv, _, _ := chatWSTestServer(t) + conn := dialChatWS(t, srv) + defer conn.Close(websocket.StatusNormalClosure, "") + + sendInitial(t, conn, []chatMessage{{Role: "user", Content: "hola"}}) + + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + ev := readWSEvent(t, conn) + switch ev.Type { + case "error": + if !strings.Contains(ev.Error, "claude exit") { + t.Fatalf("expected claude exit error, got: %s", ev.Error) + } + return + case "done": + t.Fatalf("done received before error") + } + } + t.Fatalf("never received error event") +} + +// --- /api/tool internal endpoint tests ------------------------------------ + +func internalToolServer(t *testing.T) (*httptest.Server, *DB, string) { + t.Helper() + db := setupTestDB(t) + logger := newChatLogger(filepath.Join(t.TempDir(), "log")) + token := generateInternalToken() + mux := http.NewServeMux() + mux.Handle("POST /api/tool/{name}", handleInternalTool(db, token, logger)) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv, db, token +} + +func TestInternalTool_CreateColumnRoundtrip(t *testing.T) { + srv, db, token := internalToolServer(t) + req, _ := http.NewRequest("POST", srv.URL+"/api/tool/create_column", strings.NewReader(`{"name":"Backlog"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set(internalTokenHeader, token) + resp, err := srv.Client().Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + t.Fatalf("status %d", resp.StatusCode) + } + var tr ToolResult + if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil { + t.Fatalf("decode: %v", err) + } + if !tr.OK { + t.Fatalf("create_column failed: %s", tr.Error) + } + cols, err := db.ListColumns() + if err != nil { + t.Fatalf("list: %v", err) + } + if len(cols) != 1 || cols[0].Name != "Backlog" { + t.Fatalf("expected 1 col Backlog, got %+v", cols) + } +} + +func TestInternalTool_RejectsMissingToken(t *testing.T) { + srv, _, _ := internalToolServer(t) + req, _ := http.NewRequest("POST", srv.URL+"/api/tool/create_column", strings.NewReader(`{"name":"X"}`)) + req.Header.Set("Content-Type", "application/json") + resp, err := srv.Client().Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != 401 { + t.Fatalf("expected 401, got %d", resp.StatusCode) + } +} + +func TestInternalTool_UnknownTool(t *testing.T) { + srv, _, token := internalToolServer(t) + req, _ := http.NewRequest("POST", srv.URL+"/api/tool/no_such", strings.NewReader(`{}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set(internalTokenHeader, token) + resp, err := srv.Client().Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != 404 { + t.Fatalf("expected 404, got %d", resp.StatusCode) + } +} diff --git a/backend/db.go b/backend/db.go new file mode 100644 index 0000000..42ed8a4 --- /dev/null +++ b/backend/db.go @@ -0,0 +1,1201 @@ +package main + +import ( + "database/sql" + "embed" + "encoding/json" + "fmt" + "sort" + "strings" + "time" + + "fn-registry/functions/core" + "fn-registry/functions/infra" +) + +//go:embed migrations/*.sql +var migrationsFS embed.FS + +type Column struct { + ID string `json:"id"` + Name string `json:"name"` + Position int `json:"position"` + Location string `json:"location"` + Width int `json:"width"` + WIPLimit int `json:"wip_limit"` + IsDone bool `json:"is_done"` + CreatedAt string `json:"created_at"` +} + +type Sticker struct { + Emoji string `json:"emoji"` + X float64 `json:"x"` + Y float64 `json:"y"` +} + +type Card struct { + ID string `json:"id"` + SeqNum int `json:"seq_num"` + Requester string `json:"requester"` + Title string `json:"title"` + Description string `json:"description"` + Color string `json:"color"` + ColumnID string `json:"column_id"` + Position int `json:"position"` + Locked bool `json:"locked"` + AssigneeID *string `json:"assignee_id"` + CompletedAt *string `json:"completed_at"` + DeletedAt *string `json:"deleted_at"` + Tags []string `json:"tags"` + Stickers []Sticker `json:"stickers"` + Deadline *string `json:"deadline"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + EnteredAt string `json:"entered_at"` + TimeInColumn int64 `json:"time_in_column_ms"` + LockedAt *string `json:"locked_at"` + TotalLockedMs int64 `json:"total_locked_ms"` +} + +type HistoryEntry struct { + ID string `json:"id"` + CardID string `json:"card_id"` + ColumnID string `json:"column_id"` + ColumnName string `json:"column_name"` + EnteredAt string `json:"entered_at"` + ExitedAt *string `json:"exited_at"` + DurationMs int64 `json:"duration_ms"` + ActorID *string `json:"actor_id"` +} + +type LockPeriod struct { + ID string `json:"id"` + CardID string `json:"card_id"` + LockedAt string `json:"locked_at"` + UnlockedAt *string `json:"unlocked_at"` + DurationMs int64 `json:"duration_ms"` + ActorID *string `json:"actor_id"` +} + +type CardHistoryResponse struct { + ColumnHistory []HistoryEntry `json:"column_history"` + LockPeriods []LockPeriod `json:"lock_periods"` + Events []CardEvent `json:"events"` + TotalLockedMs int64 `json:"total_locked_ms"` + CurrentlyLock bool `json:"currently_locked"` +} + +type CardEvent struct { + ID string `json:"id"` + CardID string `json:"card_id"` + Kind string `json:"kind"` + ActorID *string `json:"actor_id"` + Payload string `json:"payload"` + CreatedAt string `json:"created_at"` +} + +type DB struct{ conn *sql.DB } + +func openDB(path string) (*DB, error) { + conn, err := infra.SQLiteOpen(path, "") + if err != nil { + return nil, err + } + if err := infra.ApplyMigrations(conn, migrationsFS, "migrations/*.sql"); err != nil { + conn.Close() + return nil, fmt.Errorf("migrate: %w", err) + } + // Idempotent backstop for very old DBs whose schema diverged before + // migration files existed. New columns SIEMPRE se añaden via migracion. + if err := ensureColumns(conn); err != nil { + conn.Close() + return nil, fmt.Errorf("ensure columns: %w", err) + } + return &DB{conn: conn}, nil +} + +// ensureColumns adds columns missing from older schemas without dropping data. +// SQLite ALTER TABLE ADD COLUMN supports NOT NULL with literal DEFAULT but not CHECK, +// so location's CHECK is enforced in Go (UpdateColumn) when the column is added later. +func ensureColumns(conn *sql.DB) error { + type colSpec struct{ table, name, ddl string } + specs := []colSpec{ + {"columns", "location", "TEXT NOT NULL DEFAULT 'board'"}, + {"columns", "width", "INTEGER NOT NULL DEFAULT 300"}, + {"columns", "wip_limit", "INTEGER NOT NULL DEFAULT 0"}, + {"columns", "is_done", "INTEGER NOT NULL DEFAULT 0"}, + {"cards", "color", "TEXT NOT NULL DEFAULT ''"}, + {"cards", "locked", "INTEGER NOT NULL DEFAULT 0"}, + {"cards", "assignee_id", "TEXT"}, + {"cards", "completed_at", "TEXT"}, + {"cards", "deleted_at", "TEXT"}, + {"cards", "tags", "TEXT NOT NULL DEFAULT '[]'"}, + {"cards", "stickers", "TEXT NOT NULL DEFAULT '[]'"}, + {"cards", "deadline", "TEXT"}, + {"card_column_history", "actor_id", "TEXT"}, + {"card_lock_history", "actor_id", "TEXT"}, + } + for _, s := range specs { + exists, err := infra.ColumnExists(conn, s.table, s.name) + if err != nil { + return err + } + if exists { + continue + } + if _, err := conn.Exec(fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", s.table, s.name, s.ddl)); err != nil { + return fmt.Errorf("add %s.%s: %w", s.table, s.name, err) + } + } + if _, err := conn.Exec(`CREATE INDEX IF NOT EXISTS idx_cards_assignee ON cards(assignee_id)`); err != nil { + return fmt.Errorf("create assignee index: %w", err) + } + return nil +} + +func (db *DB) Close() error { return db.conn.Close() } + +func newID() string { + id, err := core.RandomHexID(8) + if err != nil { + panic(fmt.Errorf("kanban: cannot generate id: %w", err)) + } + return id +} + +func nowRFC3339() string { return time.Now().UTC().Format(time.RFC3339Nano) } + +func parseTags(s string) []string { + out := []string{} + if s == "" { + return out + } + if err := json.Unmarshal([]byte(s), &out); err != nil { + return []string{} + } + return out +} + +func normalizeTags(in []string) []string { + seen := map[string]struct{}{} + out := []string{} + for _, t := range in { + t = strings.TrimSpace(t) + if t == "" { + continue + } + if _, ok := seen[t]; ok { + continue + } + seen[t] = struct{}{} + out = append(out, t) + } + sort.Strings(out) + return out +} + +func encodeTags(in []string) string { + b, _ := json.Marshal(normalizeTags(in)) + return string(b) +} + +func parseStickers(s string) []Sticker { + out := []Sticker{} + if s == "" { + return out + } + if err := json.Unmarshal([]byte(s), &out); err != nil { + return []Sticker{} + } + return out +} + +func clamp01(v float64) float64 { + if v < 0 { + return 0 + } + if v > 1 { + return 1 + } + return v +} + +func normalizeStickers(in []Sticker) []Sticker { + out := make([]Sticker, 0, len(in)) + for _, s := range in { + emoji := strings.TrimSpace(s.Emoji) + if emoji == "" { + continue + } + out = append(out, Sticker{Emoji: emoji, X: clamp01(s.X), Y: clamp01(s.Y)}) + } + return out +} + +func encodeStickers(in []Sticker) string { + b, _ := json.Marshal(normalizeStickers(in)) + return string(b) +} + +func (db *DB) UpdateStickers(id string, stickers []Sticker) error { + _, err := db.conn.Exec(`UPDATE cards SET stickers=?, updated_at=? WHERE id=?`, encodeStickers(stickers), nowRFC3339(), id) + return err +} + +func (db *DB) ListAllTags() ([]string, error) { + rows, err := db.conn.Query(`SELECT DISTINCT tags FROM cards WHERE deleted_at IS NULL`) + if err != nil { + return nil, err + } + defer rows.Close() + seen := map[string]struct{}{} + for rows.Next() { + var s string + if err := rows.Scan(&s); err != nil { + return nil, err + } + for _, t := range parseTags(s) { + seen[t] = struct{}{} + } + } + out := make([]string, 0, len(seen)) + for k := range seen { + out = append(out, k) + } + sort.Strings(out) + return out, nil +} + +func (db *DB) ListDistinctRequesters() ([]string, error) { + rows, err := db.conn.Query(`SELECT DISTINCT requester FROM cards WHERE deleted_at IS NULL AND requester != '' ORDER BY requester`) + if err != nil { + return nil, err + } + defer rows.Close() + out := []string{} + for rows.Next() { + var s string + if err := rows.Scan(&s); err != nil { + return nil, err + } + out = append(out, s) + } + return out, rows.Err() +} + +func nullableActor(actorID string) any { + if actorID == "" { + return nil + } + return actorID +} + +// insertEvent registra un evento timeline de la card. tx puede ser nil para usar conn. +func insertCardEvent(execer interface { + Exec(string, ...any) (sql.Result, error) +}, cardID, kind, actorID string, payload any) error { + pj, _ := json.Marshal(payload) + _, err := execer.Exec( + `INSERT INTO card_events (id, card_id, kind, actor_id, payload, created_at) VALUES (?, ?, ?, ?, ?, ?)`, + newID(), cardID, kind, nullableActor(actorID), string(pj), nowRFC3339(), + ) + return err +} + +// --- Columns --- + +func (db *DB) ListColumns() ([]Column, error) { + rows, err := db.conn.Query(`SELECT id, name, position, location, width, wip_limit, is_done, created_at FROM columns ORDER BY position, created_at`) + if err != nil { + return nil, err + } + defer rows.Close() + out := []Column{} + for rows.Next() { + var c Column + var isDone int + if err := rows.Scan(&c.ID, &c.Name, &c.Position, &c.Location, &c.Width, &c.WIPLimit, &isDone, &c.CreatedAt); err != nil { + return nil, err + } + c.IsDone = isDone != 0 + out = append(out, c) + } + return out, rows.Err() +} + +func (db *DB) CreateColumn(name string) (*Column, error) { + var maxPos sql.NullInt64 + if err := db.conn.QueryRow(`SELECT MAX(position) FROM columns`).Scan(&maxPos); err != nil { + return nil, err + } + pos := 0 + if maxPos.Valid { + pos = int(maxPos.Int64) + 1 + } + c := Column{ID: newID(), Name: name, Position: pos, Location: "board", Width: 300, WIPLimit: 0, IsDone: false, CreatedAt: nowRFC3339()} + _, err := db.conn.Exec( + `INSERT INTO columns (id, name, position, location, width, wip_limit, is_done, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + c.ID, c.Name, c.Position, c.Location, c.Width, c.WIPLimit, 0, c.CreatedAt, + ) + if err != nil { + return nil, err + } + return &c, nil +} + +type ColumnPatch struct { + Name *string + Position *int + Location *string + Width *int + WIPLimit *int + IsDone *bool +} + +func (db *DB) UpdateColumn(id string, patch ColumnPatch) error { + if patch.Name != nil { + if _, err := db.conn.Exec(`UPDATE columns SET name=? WHERE id=?`, *patch.Name, id); err != nil { + return err + } + } + if patch.Position != nil { + if _, err := db.conn.Exec(`UPDATE columns SET position=? WHERE id=?`, *patch.Position, id); err != nil { + return err + } + } + if patch.Location != nil { + if *patch.Location != "board" && *patch.Location != "sidebar" { + return fmt.Errorf("invalid location: %s", *patch.Location) + } + if _, err := db.conn.Exec(`UPDATE columns SET location=? WHERE id=?`, *patch.Location, id); err != nil { + return err + } + } + if patch.Width != nil { + w := *patch.Width + if w < 200 { + w = 200 + } else if w > 800 { + w = 800 + } + if _, err := db.conn.Exec(`UPDATE columns SET width=? WHERE id=?`, w, id); err != nil { + return err + } + } + if patch.WIPLimit != nil { + l := *patch.WIPLimit + if l < 0 { + l = 0 + } + if _, err := db.conn.Exec(`UPDATE columns SET wip_limit=? WHERE id=?`, l, id); err != nil { + return err + } + } + if patch.IsDone != nil { + v := 0 + if *patch.IsDone { + v = 1 + } + if _, err := db.conn.Exec(`UPDATE columns SET is_done=? WHERE id=?`, v, id); err != nil { + return err + } + // Re-evaluate completed_at for cards in this column. + now := nowRFC3339() + if v == 1 { + if _, err := db.conn.Exec(`UPDATE cards SET completed_at=? WHERE column_id=? AND completed_at IS NULL`, now, id); err != nil { + return err + } + } else { + if _, err := db.conn.Exec(`UPDATE cards SET completed_at=NULL WHERE column_id=?`, id); err != nil { + return err + } + } + } + return nil +} + +func (db *DB) DeleteColumn(id string) error { + _, err := db.conn.Exec(`DELETE FROM columns WHERE id=?`, id) + return err +} + +func (db *DB) ReorderColumns(ids []string) error { + tx, err := db.conn.Begin() + if err != nil { + return err + } + defer tx.Rollback() + for i, id := range ids { + if _, err := tx.Exec(`UPDATE columns SET position=? WHERE id=?`, i, id); err != nil { + return err + } + } + return tx.Commit() +} + +// --- Cards --- + +func (db *DB) ListCardsWithTime() ([]Card, error) { + rows, err := db.conn.Query(` + SELECT c.id, c.seq_num, c.requester, c.title, c.description, c.color, c.column_id, c.position, c.locked, c.assignee_id, c.completed_at, c.deleted_at, c.tags, c.stickers, c.deadline, c.created_at, c.updated_at, + h.entered_at, l.locked_at, + COALESCE(( + SELECT CAST(SUM((julianday(COALESCE(unlocked_at, ?)) - julianday(locked_at)) * 86400000) AS INTEGER) + FROM card_lock_history WHERE card_id = c.id + ), 0) AS total_locked_ms + FROM cards c + LEFT JOIN card_column_history h + ON h.card_id = c.id AND h.exited_at IS NULL + LEFT JOIN card_lock_history l + ON l.card_id = c.id AND l.unlocked_at IS NULL + WHERE c.deleted_at IS NULL + ORDER BY c.column_id, c.position, c.created_at + `, time.Now().UTC().Format(time.RFC3339Nano)) + if err != nil { + return nil, err + } + defer rows.Close() + now := time.Now().UTC() + out := []Card{} + for rows.Next() { + var c Card + var entered sql.NullString + var assignee sql.NullString + var completed sql.NullString + var deleted sql.NullString + var tagsJSON string + var stickersJSON string + var deadline sql.NullString + var lockedAt sql.NullString + var locked int + if err := rows.Scan(&c.ID, &c.SeqNum, &c.Requester, &c.Title, &c.Description, &c.Color, &c.ColumnID, &c.Position, &locked, &assignee, &completed, &deleted, &tagsJSON, &stickersJSON, &deadline, &c.CreatedAt, &c.UpdatedAt, &entered, &lockedAt, &c.TotalLockedMs); err != nil { + return nil, err + } + c.Stickers = parseStickers(stickersJSON) + if deadline.Valid && deadline.String != "" { + s := deadline.String + c.Deadline = &s + } + if lockedAt.Valid && lockedAt.String != "" { + s := lockedAt.String + c.LockedAt = &s + } + c.Locked = locked != 0 + if assignee.Valid && assignee.String != "" { + s := assignee.String + c.AssigneeID = &s + } + if completed.Valid && completed.String != "" { + s := completed.String + c.CompletedAt = &s + } + if deleted.Valid && deleted.String != "" { + s := deleted.String + c.DeletedAt = &s + } + c.Tags = parseTags(tagsJSON) + if entered.Valid { + c.EnteredAt = entered.String + if t, err := time.Parse(time.RFC3339Nano, entered.String); err == nil { + c.TimeInColumn = now.Sub(t).Milliseconds() + } + } + out = append(out, c) + } + return out, rows.Err() +} + +func (db *DB) CreateCard(columnID, requester, title, description, actorID string) (*Card, error) { + var maxPos sql.NullInt64 + if err := db.conn.QueryRow(`SELECT MAX(position) FROM cards WHERE column_id=?`, columnID).Scan(&maxPos); err != nil { + return nil, err + } + pos := 0 + if maxPos.Valid { + pos = int(maxPos.Int64) + 1 + } + now := nowRFC3339() + tx, err := db.conn.Begin() + if err != nil { + return nil, err + } + defer tx.Rollback() + var maxSeq sql.NullInt64 + if err := tx.QueryRow(`SELECT MAX(seq_num) FROM cards`).Scan(&maxSeq); err != nil { + return nil, err + } + seqNum := 1 + if maxSeq.Valid { + seqNum = int(maxSeq.Int64) + 1 + } + c := Card{ + ID: newID(), SeqNum: seqNum, Requester: requester, Title: title, Description: description, ColumnID: columnID, Position: pos, + Tags: []string{}, + Stickers: []Sticker{}, + CreatedAt: now, UpdatedAt: now, EnteredAt: now, + } + if _, err := tx.Exec( + `INSERT INTO cards (id, seq_num, requester, title, description, color, column_id, position, tags, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + c.ID, c.SeqNum, c.Requester, c.Title, c.Description, c.Color, c.ColumnID, c.Position, encodeTags(c.Tags), c.CreatedAt, c.UpdatedAt, + ); err != nil { + return nil, err + } + if _, err := tx.Exec( + `INSERT INTO card_column_history (id, card_id, column_id, entered_at, actor_id) VALUES (?, ?, ?, ?, ?)`, + newID(), c.ID, c.ColumnID, now, nullableActor(actorID), + ); err != nil { + return nil, err + } + // If the destination column is_done, set completed_at. + var destDone int + if err := tx.QueryRow(`SELECT is_done FROM columns WHERE id=?`, columnID).Scan(&destDone); err != nil { + return nil, err + } + if destDone == 1 { + if _, err := tx.Exec(`UPDATE cards SET completed_at=? WHERE id=?`, now, c.ID); err != nil { + return nil, err + } + c.CompletedAt = &now + } + if err := insertCardEvent(tx, c.ID, "created", actorID, map[string]any{"title": title, "column_id": columnID}); err != nil { + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, err + } + return &c, nil +} + +type CardPatch struct { + Requester *string + Title *string + Description *string + Color *string + Locked *bool + AssigneeID *string // empty string clears assignment + HasAssignee bool // distinguishes "set to null" from "not provided" + Tags *[]string + Deadline *string // empty string clears deadline + HasDeadline bool // distinguishes "set to null" from "not provided" +} + +func (db *DB) UpdateCard(id string, patch CardPatch) error { + return db.UpdateCardWithActor(id, patch, "") +} + +func (db *DB) UpdateCardWithActor(id string, patch CardPatch, actorID string) error { + tx, err := db.conn.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if patch.Requester != nil { + var oldReq string + _ = tx.QueryRow(`SELECT requester FROM cards WHERE id=?`, id).Scan(&oldReq) + if _, err := tx.Exec(`UPDATE cards SET requester=?, updated_at=? WHERE id=?`, *patch.Requester, nowRFC3339(), id); err != nil { + return err + } + if oldReq != *patch.Requester { + if err := insertCardEvent(tx, id, "requester_changed", actorID, map[string]any{"old": oldReq, "new": *patch.Requester}); err != nil { + return err + } + } + } + if patch.Title != nil { + var oldTitle string + _ = tx.QueryRow(`SELECT title FROM cards WHERE id=?`, id).Scan(&oldTitle) + if _, err := tx.Exec(`UPDATE cards SET title=?, updated_at=? WHERE id=?`, *patch.Title, nowRFC3339(), id); err != nil { + return err + } + if oldTitle != *patch.Title { + if err := insertCardEvent(tx, id, "title_changed", actorID, map[string]any{"old": oldTitle, "new": *patch.Title}); err != nil { + return err + } + } + } + if patch.Description != nil { + var oldDesc string + _ = tx.QueryRow(`SELECT description FROM cards WHERE id=?`, id).Scan(&oldDesc) + if _, err := tx.Exec(`UPDATE cards SET description=?, updated_at=? WHERE id=?`, *patch.Description, nowRFC3339(), id); err != nil { + return err + } + if oldDesc != *patch.Description { + if err := insertCardEvent(tx, id, "description_changed", actorID, map[string]any{}); err != nil { + return err + } + } + } + if patch.Color != nil { + if _, err := tx.Exec(`UPDATE cards SET color=?, updated_at=? WHERE id=?`, *patch.Color, nowRFC3339(), id); err != nil { + return err + } + if err := insertCardEvent(tx, id, "color_changed", actorID, map[string]any{"color": *patch.Color}); err != nil { + return err + } + } + if patch.HasAssignee { + var oldAssignee sql.NullString + _ = tx.QueryRow(`SELECT assignee_id FROM cards WHERE id=?`, id).Scan(&oldAssignee) + if patch.AssigneeID == nil || *patch.AssigneeID == "" { + if _, err := tx.Exec(`UPDATE cards SET assignee_id=NULL, updated_at=? WHERE id=?`, nowRFC3339(), id); err != nil { + return err + } + if oldAssignee.Valid && oldAssignee.String != "" { + if err := insertCardEvent(tx, id, "unassigned", actorID, map[string]any{"prev": oldAssignee.String}); err != nil { + return err + } + } + } else { + if _, err := tx.Exec(`UPDATE cards SET assignee_id=?, updated_at=? WHERE id=?`, *patch.AssigneeID, nowRFC3339(), id); err != nil { + return err + } + if oldAssignee.String != *patch.AssigneeID { + if err := insertCardEvent(tx, id, "assigned", actorID, map[string]any{"assignee_id": *patch.AssigneeID}); err != nil { + return err + } + } + } + } + if patch.Tags != nil { + if _, err := tx.Exec(`UPDATE cards SET tags=?, updated_at=? WHERE id=?`, encodeTags(*patch.Tags), nowRFC3339(), id); err != nil { + return err + } + if err := insertCardEvent(tx, id, "tags_changed", actorID, map[string]any{"tags": *patch.Tags}); err != nil { + return err + } + } + if patch.HasDeadline { + var oldDeadline sql.NullString + _ = tx.QueryRow(`SELECT deadline FROM cards WHERE id=?`, id).Scan(&oldDeadline) + if patch.Deadline == nil || *patch.Deadline == "" { + if _, err := tx.Exec(`UPDATE cards SET deadline=NULL, updated_at=? WHERE id=?`, nowRFC3339(), id); err != nil { + return err + } + if oldDeadline.Valid && oldDeadline.String != "" { + if err := insertCardEvent(tx, id, "deadline_cleared", actorID, map[string]any{"prev": oldDeadline.String}); err != nil { + return err + } + } + } else { + if _, err := tx.Exec(`UPDATE cards SET deadline=?, updated_at=? WHERE id=?`, *patch.Deadline, nowRFC3339(), id); err != nil { + return err + } + if oldDeadline.String != *patch.Deadline { + if err := insertCardEvent(tx, id, "deadline_set", actorID, map[string]any{"deadline": *patch.Deadline}); err != nil { + return err + } + } + } + } + if patch.Locked != nil { + var current int + if err := tx.QueryRow(`SELECT locked FROM cards WHERE id=?`, id).Scan(¤t); err != nil { + return err + } + desired := 0 + if *patch.Locked { + desired = 1 + } + if current != desired { + now := nowRFC3339() + if _, err := tx.Exec(`UPDATE cards SET locked=?, updated_at=? WHERE id=?`, desired, now, id); err != nil { + return err + } + if desired == 1 { + if _, err := tx.Exec( + `INSERT INTO card_lock_history (id, card_id, locked_at, actor_id) VALUES (?, ?, ?, ?)`, + newID(), id, now, nullableActor(actorID), + ); err != nil { + return err + } + } else { + if _, err := tx.Exec( + `UPDATE card_lock_history SET unlocked_at=? WHERE card_id=? AND unlocked_at IS NULL`, + now, id, + ); err != nil { + return err + } + } + } + } + return tx.Commit() +} + +// DeleteCard soft-deletes the card (moves it to trash). +func (db *DB) DeleteCard(id string) error { + return db.DeleteCardWithActor(id, "") +} + +func (db *DB) DeleteCardWithActor(id, actorID string) error { + tx, err := db.conn.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.Exec(`UPDATE cards SET deleted_at=?, updated_at=? WHERE id=?`, nowRFC3339(), nowRFC3339(), id); err != nil { + return err + } + if err := insertCardEvent(tx, id, "deleted", actorID, map[string]any{}); err != nil { + return err + } + return tx.Commit() +} + +// RestoreCard removes the deleted_at flag. +func (db *DB) RestoreCard(id string) error { + return db.RestoreCardWithActor(id, "") +} + +func (db *DB) RestoreCardWithActor(id, actorID string) error { + tx, err := db.conn.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.Exec(`UPDATE cards SET deleted_at=NULL, updated_at=? WHERE id=?`, nowRFC3339(), id); err != nil { + return err + } + if err := insertCardEvent(tx, id, "restored", actorID, map[string]any{}); err != nil { + return err + } + return tx.Commit() +} + +// PurgeCard permanently removes the card from the DB. +func (db *DB) PurgeCard(id string) error { + _, err := db.conn.Exec(`DELETE FROM cards WHERE id=?`, id) + return err +} + +// ListDeletedCards returns cards in the trash, newest first. +func (db *DB) ListDeletedCards() ([]Card, error) { + rows, err := db.conn.Query(` + SELECT c.id, c.seq_num, c.requester, c.title, c.description, c.color, c.column_id, c.position, c.locked, c.assignee_id, c.completed_at, c.deleted_at, c.tags, c.stickers, c.deadline, c.created_at, c.updated_at + FROM cards c + WHERE c.deleted_at IS NOT NULL + ORDER BY c.deleted_at DESC + `) + if err != nil { + return nil, err + } + defer rows.Close() + out := []Card{} + for rows.Next() { + var c Card + var assignee sql.NullString + var completed sql.NullString + var deleted sql.NullString + var tagsJSON string + var stickersJSON string + var deadline sql.NullString + var locked int + if err := rows.Scan(&c.ID, &c.SeqNum, &c.Requester, &c.Title, &c.Description, &c.Color, &c.ColumnID, &c.Position, &locked, &assignee, &completed, &deleted, &tagsJSON, &stickersJSON, &deadline, &c.CreatedAt, &c.UpdatedAt); err != nil { + return nil, err + } + c.Stickers = parseStickers(stickersJSON) + if deadline.Valid && deadline.String != "" { + s := deadline.String + c.Deadline = &s + } + c.Locked = locked != 0 + if assignee.Valid && assignee.String != "" { + s := assignee.String + c.AssigneeID = &s + } + if completed.Valid && completed.String != "" { + s := completed.String + c.CompletedAt = &s + } + if deleted.Valid { + s := deleted.String + c.DeletedAt = &s + } + c.Tags = parseTags(tagsJSON) + out = append(out, c) + } + return out, rows.Err() +} + +// MoveCard updates the card's column and/or position. If the column changes, +// the open history entry is closed and a new one is opened. +// orderedIDs is the new order of cards in the destination column (including this card). +// actorID is the user performing the move (empty string for system/anonymous). +func (db *DB) MoveCard(cardID, destColumnID string, orderedIDs []string, actorID string) error { + tx, err := db.conn.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + var srcColumnID string + var locked int + if err := tx.QueryRow(`SELECT column_id, locked FROM cards WHERE id=?`, cardID).Scan(&srcColumnID, &locked); err != nil { + return fmt.Errorf("card not found: %w", err) + } + if locked != 0 && srcColumnID != destColumnID { + return fmt.Errorf("card locked: cannot move between columns") + } + + now := nowRFC3339() + + if srcColumnID != destColumnID { + if _, err := tx.Exec( + `UPDATE card_column_history SET exited_at=? WHERE card_id=? AND exited_at IS NULL`, + now, cardID, + ); err != nil { + return err + } + if _, err := tx.Exec( + `INSERT INTO card_column_history (id, card_id, column_id, entered_at, actor_id) VALUES (?, ?, ?, ?, ?)`, + newID(), cardID, destColumnID, now, nullableActor(actorID), + ); err != nil { + return err + } + _ = actorID + if _, err := tx.Exec( + `UPDATE cards SET column_id=?, updated_at=? WHERE id=?`, + destColumnID, now, cardID, + ); err != nil { + return err + } + // Recompute completed_at based on destination column's is_done flag. + var destDone int + if err := tx.QueryRow(`SELECT is_done FROM columns WHERE id=?`, destColumnID).Scan(&destDone); err != nil { + return err + } + if destDone == 1 { + if _, err := tx.Exec(`UPDATE cards SET completed_at=? WHERE id=? AND completed_at IS NULL`, now, cardID); err != nil { + return err + } + // Auto-assign: if card had no assignee and an actor is moving it, claim it. + if actorID != "" { + if _, err := tx.Exec(`UPDATE cards SET assignee_id=? WHERE id=? AND (assignee_id IS NULL OR assignee_id='')`, actorID, cardID); err != nil { + return err + } + } + } else { + if _, err := tx.Exec(`UPDATE cards SET completed_at=NULL WHERE id=?`, cardID); err != nil { + return err + } + } + } + + for i, id := range orderedIDs { + if _, err := tx.Exec(`UPDATE cards SET position=? WHERE id=?`, i, id); err != nil { + return err + } + } + + // Re-pack source column positions to keep them dense. + if srcColumnID != destColumnID { + rows, err := tx.Query(`SELECT id FROM cards WHERE column_id=? ORDER BY position, created_at`, srcColumnID) + if err != nil { + return err + } + var srcIDs []string + for rows.Next() { + var sid string + if err := rows.Scan(&sid); err != nil { + rows.Close() + return err + } + srcIDs = append(srcIDs, sid) + } + rows.Close() + for i, sid := range srcIDs { + if _, err := tx.Exec(`UPDATE cards SET position=? WHERE id=?`, i, sid); err != nil { + return err + } + } + } + + return tx.Commit() +} + +func (db *DB) CardHistory(cardID string) (*CardHistoryResponse, error) { + rows, err := db.conn.Query(` + SELECT h.id, h.card_id, h.column_id, COALESCE(c.name, ''), h.entered_at, h.exited_at, h.actor_id + FROM card_column_history h + LEFT JOIN columns c ON c.id = h.column_id + WHERE h.card_id=? + ORDER BY h.entered_at + `, cardID) + if err != nil { + return nil, err + } + defer rows.Close() + now := time.Now().UTC() + cols := []HistoryEntry{} + for rows.Next() { + var h HistoryEntry + var exited sql.NullString + var actor sql.NullString + if err := rows.Scan(&h.ID, &h.CardID, &h.ColumnID, &h.ColumnName, &h.EnteredAt, &exited, &actor); err != nil { + return nil, err + } + if actor.Valid && actor.String != "" { + s := actor.String + h.ActorID = &s + } + entered, err := time.Parse(time.RFC3339Nano, h.EnteredAt) + if err != nil { + return nil, err + } + var end time.Time + if exited.Valid { + h.ExitedAt = &exited.String + end, _ = time.Parse(time.RFC3339Nano, exited.String) + } else { + end = now + } + h.DurationMs = end.Sub(entered).Milliseconds() + cols = append(cols, h) + } + if err := rows.Err(); err != nil { + return nil, err + } + + lockRows, err := db.conn.Query(` + SELECT id, card_id, locked_at, unlocked_at, actor_id + FROM card_lock_history + WHERE card_id=? + ORDER BY locked_at + `, cardID) + if err != nil { + return nil, err + } + defer lockRows.Close() + locks := []LockPeriod{} + var totalLocked int64 + currently := false + for lockRows.Next() { + var lp LockPeriod + var unlocked sql.NullString + var actor sql.NullString + if err := lockRows.Scan(&lp.ID, &lp.CardID, &lp.LockedAt, &unlocked, &actor); err != nil { + return nil, err + } + if actor.Valid && actor.String != "" { + s := actor.String + lp.ActorID = &s + } + start, err := time.Parse(time.RFC3339Nano, lp.LockedAt) + if err != nil { + return nil, err + } + var end time.Time + if unlocked.Valid { + lp.UnlockedAt = &unlocked.String + end, _ = time.Parse(time.RFC3339Nano, unlocked.String) + } else { + end = now + currently = true + } + lp.DurationMs = end.Sub(start).Milliseconds() + totalLocked += lp.DurationMs + locks = append(locks, lp) + } + if err := lockRows.Err(); err != nil { + return nil, err + } + + evRows, err := db.conn.Query(` + SELECT id, card_id, kind, actor_id, payload, created_at + FROM card_events + WHERE card_id=? + ORDER BY created_at + `, cardID) + if err != nil { + return nil, err + } + defer evRows.Close() + events := []CardEvent{} + for evRows.Next() { + var e CardEvent + var actor sql.NullString + if err := evRows.Scan(&e.ID, &e.CardID, &e.Kind, &actor, &e.Payload, &e.CreatedAt); err != nil { + return nil, err + } + if actor.Valid && actor.String != "" { + s := actor.String + e.ActorID = &s + } + events = append(events, e) + } + + return &CardHistoryResponse{ + ColumnHistory: cols, + LockPeriods: locks, + Events: events, + TotalLockedMs: totalLocked, + CurrentlyLock: currently, + }, nil +} + +type CardMessage struct { + ID string `json:"id"` + CardID string `json:"card_id"` + AuthorID *string `json:"author_id"` + Body string `json:"body"` + CreatedAt string `json:"created_at"` +} + +func (db *DB) ListCardMessages(cardID string) ([]CardMessage, error) { + rows, err := db.conn.Query( + `SELECT id, card_id, author_id, body, created_at FROM card_messages WHERE card_id=? ORDER BY created_at`, + cardID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + out := []CardMessage{} + for rows.Next() { + var m CardMessage + var author sql.NullString + if err := rows.Scan(&m.ID, &m.CardID, &author, &m.Body, &m.CreatedAt); err != nil { + return nil, err + } + if author.Valid && author.String != "" { + s := author.String + m.AuthorID = &s + } + out = append(out, m) + } + return out, rows.Err() +} + +func (db *DB) CreateCardMessage(cardID, authorID, body string) (*CardMessage, error) { + body = strings.TrimSpace(body) + if body == "" { + return nil, fmt.Errorf("body required") + } + if authorID == "" { + return nil, fmt.Errorf("author required") + } + var exists int + if err := db.conn.QueryRow(`SELECT 1 FROM cards WHERE id=?`, cardID).Scan(&exists); err != nil { + return nil, fmt.Errorf("card not found: %w", err) + } + s := authorID + m := &CardMessage{ID: newID(), CardID: cardID, AuthorID: &s, Body: body, CreatedAt: nowRFC3339()} + if _, err := db.conn.Exec( + `INSERT INTO card_messages (id, card_id, author_id, body, created_at) VALUES (?, ?, ?, ?, ?)`, + m.ID, m.CardID, authorID, m.Body, m.CreatedAt, + ); err != nil { + return nil, err + } + return m, nil +} + +func (db *DB) DeleteCardMessage(id, requesterID string) error { + if requesterID == "" { + return fmt.Errorf("session required") + } + res, err := db.conn.Exec(`DELETE FROM card_messages WHERE id=? AND author_id=?`, id, requesterID) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return fmt.Errorf("not found or not author") + } + return nil +} + +// DuplicateCard clones a card into the same column at the end of the list. +// Copies title, description, color, requester, assignee, tags, deadline, stickers. +// Does NOT copy card_column_history, card_lock_history, card_events, card_messages. +// Title gets " (copia)" suffix. +func (db *DB) DuplicateCard(srcID, actorID string) (*Card, error) { + tx, err := db.conn.Begin() + if err != nil { + return nil, err + } + defer tx.Rollback() + + var src Card + var assignee sql.NullString + var deadline sql.NullString + var tagsJSON, stickersJSON string + if err := tx.QueryRow( + `SELECT requester, title, description, color, column_id, assignee_id, tags, stickers, deadline + FROM cards WHERE id=? AND deleted_at IS NULL`, srcID, + ).Scan(&src.Requester, &src.Title, &src.Description, &src.Color, &src.ColumnID, &assignee, &tagsJSON, &stickersJSON, &deadline); err != nil { + return nil, fmt.Errorf("card not found: %w", err) + } + if assignee.Valid && assignee.String != "" { + s := assignee.String + src.AssigneeID = &s + } + if deadline.Valid && deadline.String != "" { + s := deadline.String + src.Deadline = &s + } + src.Tags = parseTags(tagsJSON) + src.Stickers = parseStickers(stickersJSON) + + var maxPos sql.NullInt64 + if err := tx.QueryRow(`SELECT MAX(position) FROM cards WHERE column_id=?`, src.ColumnID).Scan(&maxPos); err != nil { + return nil, err + } + pos := 0 + if maxPos.Valid { + pos = int(maxPos.Int64) + 1 + } + var maxSeq sql.NullInt64 + if err := tx.QueryRow(`SELECT MAX(seq_num) FROM cards`).Scan(&maxSeq); err != nil { + return nil, err + } + seqNum := 1 + if maxSeq.Valid { + seqNum = int(maxSeq.Int64) + 1 + } + now := nowRFC3339() + newTitle := src.Title + " (copia)" + c := Card{ + ID: newID(), SeqNum: seqNum, Requester: src.Requester, Title: newTitle, + Description: src.Description, Color: src.Color, ColumnID: src.ColumnID, Position: pos, + AssigneeID: src.AssigneeID, Tags: src.Tags, Stickers: src.Stickers, Deadline: src.Deadline, + CreatedAt: now, UpdatedAt: now, EnteredAt: now, + } + var assigneeVal any + if c.AssigneeID != nil && *c.AssigneeID != "" { + assigneeVal = *c.AssigneeID + } + var deadlineVal any + if c.Deadline != nil && *c.Deadline != "" { + deadlineVal = *c.Deadline + } + if _, err := tx.Exec( + `INSERT INTO cards (id, seq_num, requester, title, description, color, column_id, position, assignee_id, tags, stickers, deadline, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + c.ID, c.SeqNum, c.Requester, c.Title, c.Description, c.Color, c.ColumnID, c.Position, + assigneeVal, encodeTags(c.Tags), encodeStickers(c.Stickers), deadlineVal, c.CreatedAt, c.UpdatedAt, + ); err != nil { + return nil, err + } + if _, err := tx.Exec( + `INSERT INTO card_column_history (id, card_id, column_id, entered_at, actor_id) VALUES (?, ?, ?, ?, ?)`, + newID(), c.ID, c.ColumnID, now, nullableActor(actorID), + ); err != nil { + return nil, err + } + var destDone int + if err := tx.QueryRow(`SELECT is_done FROM columns WHERE id=?`, c.ColumnID).Scan(&destDone); err != nil { + return nil, err + } + if destDone == 1 { + if _, err := tx.Exec(`UPDATE cards SET completed_at=? WHERE id=?`, now, c.ID); err != nil { + return nil, err + } + c.CompletedAt = &now + } + if err := insertCardEvent(tx, c.ID, "created", actorID, map[string]any{"title": newTitle, "column_id": c.ColumnID, "duplicated_from": srcID}); err != nil { + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, err + } + return &c, nil +} diff --git a/backend/dist/.gitkeep b/backend/dist/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/backend/flags.go b/backend/flags.go new file mode 100644 index 0000000..0a089bf --- /dev/null +++ b/backend/flags.go @@ -0,0 +1,55 @@ +package main + +import ( + "encoding/json" + "net/http" + "os" + + "fn-registry/functions/infra" +) + +type FeatureFlag struct { + Enabled bool `json:"enabled"` + Issue string `json:"issue,omitempty"` + Description string `json:"description"` + Added string `json:"added,omitempty"` + EnabledAt string `json:"enabled_at,omitempty"` +} + +type FeatureFlags struct { + Flags map[string]FeatureFlag `json:"flags"` +} + +func (f FeatureFlags) Enabled(name string) bool { + flag, ok := f.Flags[name] + return ok && flag.Enabled +} + +func loadFeatureFlags(path string) (FeatureFlags, error) { + b, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return FeatureFlags{Flags: map[string]FeatureFlag{}}, nil + } + return FeatureFlags{}, err + } + var f FeatureFlags + if err := json.Unmarshal(b, &f); err != nil { + return FeatureFlags{}, err + } + if f.Flags == nil { + f.Flags = map[string]FeatureFlag{} + } + return f, nil +} + +// GET /api/flags → { "": true/false, ... } +func handleListFlags(flags *FeatureFlags) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + out := make(map[string]bool, len(flags.Flags)) + for name, fl := range flags.Flags { + out[name] = fl.Enabled + } + infra.HTTPJSONResponse(w, http.StatusOK, out) + } +} diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..9df7726 --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,49 @@ +module kanban + +go 1.25.0 + +require fn-registry v0.0.0-00010101000000-000000000000 + +require ( + github.com/ClickHouse/ch-go v0.71.0 // indirect + github.com/ClickHouse/clickhouse-go/v2 v2.44.0 // indirect + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/apache/arrow-go/v18 v18.1.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/go-faster/city v1.0.1 // indirect + github.com/go-faster/errors v0.7.1 // indirect + github.com/go-viper/mapstructure/v2 v2.2.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/google/flatbuffers v25.1.24+incompatible // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.9.1 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/klauspost/compress v1.18.3 // indirect + github.com/klauspost/cpuid/v2 v2.2.9 // indirect + github.com/marcboeker/go-duckdb v1.8.5 // indirect + github.com/mattn/go-sqlite3 v1.14.37 // indirect + github.com/paulmach/orb v0.12.0 // indirect + github.com/pierrec/lz4/v4 v4.1.25 // indirect + github.com/segmentio/asm v1.2.1 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/zeebo/xxh3 v1.0.2 // indirect + go.opentelemetry.io/otel v1.41.0 // indirect + go.opentelemetry.io/otel/trace v1.41.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c // indirect + golang.org/x/mod v0.34.0 // indirect + golang.org/x/net v0.53.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c // indirect + golang.org/x/text v0.36.0 // indirect + golang.org/x/tools v0.43.0 // indirect + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + nhooyr.io/websocket v1.8.17 // indirect +) + +replace fn-registry => ../../.. diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..6cc7880 --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,176 @@ +github.com/ClickHouse/ch-go v0.71.0 h1:bUdZ/EZj/LcVHsMqaRUP2holqygrPWQKeMjc6nZoyRM= +github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw= +github.com/ClickHouse/clickhouse-go/v2 v2.44.0 h1:9pxs5pRwIvhni5BDRPn/n5A8DeUod5TnBaeulFBX8EQ= +github.com/ClickHouse/clickhouse-go/v2 v2.44.0/go.mod h1:giJfUVlMkcfUEPVfRpt51zZaGEx9i17gCos8gBl392c= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/apache/arrow-go/v18 v18.1.0 h1:agLwJUiVuwXZdwPYVrlITfx7bndULJ/dggbnLFgDp/Y= +github.com/apache/arrow-go/v18 v18.1.0/go.mod h1:tigU/sIgKNXaesf5d7Y95jBBKS5KsxTqYBKXFsvKzo0= +github.com/apache/thrift v0.21.0 h1:tdPmh/ptjE1IJnhbhrcl2++TauVjy242rkV/UzJChnE= +github.com/apache/thrift v0.21.0/go.mod h1:W1H8aR/QRtYNvrPeFXBtobyRkd0/YVhTc6i07XIAgDw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw= +github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw= +github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg= +github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= +github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= +github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/flatbuffers v25.1.24+incompatible h1:4wPqL3K7GzBd1CwyhSd3usxLKOaJN/AC6puCca6Jm7o= +github.com/google/flatbuffers v25.1.24+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc= +github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/asmfmt v1.3.2 h1:4Ri7ox3EwapiOjCki+hw14RyKk201CN4rzyCJRFLpK4= +github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE= +github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= +github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= +github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= +github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/marcboeker/go-duckdb v1.8.5 h1:tkYp+TANippy0DaIOP5OEfBEwbUINqiFqgwMQ44jME0= +github.com/marcboeker/go-duckdb v1.8.5/go.mod h1:6mK7+WQE4P4u5AFLvVBmhFxY5fvhymFptghgJX6B+/8= +github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg= +github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 h1:AMFGa4R4MiIpspGNG7Z948v4n35fFGB3RR3G/ry4FWs= +github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY= +github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 h1:+n/aFZefKZp7spd8DFdX7uMikMLXX4oubIzJF4kv/wI= +github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE= +github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= +github.com/paulmach/orb v0.12.0 h1:z+zOwjmG3MyEEqzv92UN49Lg1JFYx0L9GpGKNVDKk1s= +github.com/paulmach/orb v0.12.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU= +github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY= +github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0= +github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g= +github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g= +go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= +go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= +go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= +go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c h1:KL/ZBHXgKGVmuZBZ01Lt57yE5ws8ZPSkkihmEyq7FXc= +golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c h1:6a8FdnNk6bTXBjR4AGKFgUKuo+7GnR3FX5L7CbveeZc= +golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c/go.mod h1:TpUTTEp9frx7rTdLpC9gFG9kdI7zVLFTFFlqaH2Cncw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= +gonum.org/v1/gonum v0.15.1 h1:FNy7N6OUZVUaWG9pTiD+jlhdQ3lMP+/LcTpJ6+a8sQ0= +gonum.org/v1/gonum v0.15.1/go.mod h1:eZTZuRFrzu5pcyjN5wJhcIhnUdNijYxX1T2IcrOGY0o= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y= +nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= diff --git a/backend/handlers.go b/backend/handlers.go new file mode 100644 index 0000000..e4c2e84 --- /dev/null +++ b/backend/handlers.go @@ -0,0 +1,474 @@ +package main + +import ( + "net/http" + "strings" + + "fn-registry/functions/infra" +) + +const maxBodyBytes = 1 << 20 // 1 MiB + +func badRequest(w http.ResponseWriter, msg string) { + infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusBadRequest, Code: "bad_request", Message: msg}) +} + +func notFound(w http.ResponseWriter, msg string) { + infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusNotFound, Code: "not_found", Message: msg}) +} + +func serverError(w http.ResponseWriter, err error) { + infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusInternalServerError, Code: "internal", Message: err.Error()}) +} + +// GET /api/board → { columns: [...], cards: [...] } +func handleGetBoard(db *DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + cols, err := db.ListColumns() + if err != nil { + serverError(w, err) + return + } + cards, err := db.ListCardsWithTime() + if err != nil { + serverError(w, err) + return + } + infra.HTTPJSONResponse(w, http.StatusOK, map[string]any{ + "columns": cols, + "cards": cards, + }) + } +} + +// POST /api/columns { name } +func handleCreateColumn(db *DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var body struct{ Name string `json:"name"` } + if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil { + badRequest(w, err.Error()) + return + } + if strings.TrimSpace(body.Name) == "" { + badRequest(w, "name required") + return + } + c, err := db.CreateColumn(body.Name) + if err != nil { + serverError(w, err) + return + } + infra.HTTPJSONResponse(w, http.StatusCreated, c) + } +} + +// PATCH /api/columns/{id} { name?, position?, location?, width? } +func handleUpdateColumn(db *DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + var body struct { + Name *string `json:"name"` + Position *int `json:"position"` + Location *string `json:"location"` + Width *int `json:"width"` + WIPLimit *int `json:"wip_limit"` + IsDone *bool `json:"is_done"` + } + if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil { + badRequest(w, err.Error()) + return + } + if err := db.UpdateColumn(id, ColumnPatch{Name: body.Name, Position: body.Position, Location: body.Location, Width: body.Width, WIPLimit: body.WIPLimit, IsDone: body.IsDone}); err != nil { + serverError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) + } +} + +// DELETE /api/columns/{id} +func handleDeleteColumn(db *DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if err := db.DeleteColumn(id); err != nil { + serverError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) + } +} + +// POST /api/columns/reorder { ids: [...] } +func handleReorderColumns(db *DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var body struct{ IDs []string `json:"ids"` } + if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil { + badRequest(w, err.Error()) + return + } + if err := db.ReorderColumns(body.IDs); err != nil { + serverError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) + } +} + +// POST /api/cards { column_id, requester?, title, description? } +func handleCreateCard(db *DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var body struct { + ColumnID string `json:"column_id"` + Requester string `json:"requester"` + Title string `json:"title"` + Description string `json:"description"` + AssigneeID *string `json:"assignee_id"` + Tags []string `json:"tags"` + } + if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil { + badRequest(w, err.Error()) + return + } + if body.ColumnID == "" || strings.TrimSpace(body.Title) == "" { + badRequest(w, "column_id and title required") + return + } + actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey) + c, err := db.CreateCard(body.ColumnID, body.Requester, body.Title, body.Description, actor) + if err == nil && body.AssigneeID != nil && *body.AssigneeID != "" { + err = db.UpdateCardWithActor(c.ID, CardPatch{AssigneeID: body.AssigneeID, HasAssignee: true}, actor) + if err == nil { + c.AssigneeID = body.AssigneeID + } + } + if err == nil && len(body.Tags) > 0 { + tags := body.Tags + err = db.UpdateCardWithActor(c.ID, CardPatch{Tags: &tags}, actor) + if err == nil { + c.Tags = tags + } + } + if err != nil { + serverError(w, err) + return + } + infra.HTTPJSONResponse(w, http.StatusCreated, c) + } +} + +// PATCH /api/cards/{id} { requester?, title?, description?, color? } +func handleUpdateCard(db *DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + var raw map[string]any + if err := infra.HTTPParseBody(r, &raw, maxBodyBytes); err != nil { + badRequest(w, err.Error()) + return + } + patch := CardPatch{} + if v, ok := raw["requester"].(string); ok { + patch.Requester = &v + } + if v, ok := raw["title"].(string); ok { + patch.Title = &v + } + if v, ok := raw["description"].(string); ok { + patch.Description = &v + } + if v, ok := raw["color"].(string); ok { + patch.Color = &v + } + if v, ok := raw["locked"].(bool); ok { + patch.Locked = &v + } + if v, present := raw["assignee_id"]; present { + patch.HasAssignee = true + if v == nil { + empty := "" + patch.AssigneeID = &empty + } else if s, ok := v.(string); ok { + patch.AssigneeID = &s + } + } + if v, present := raw["deadline"]; present { + patch.HasDeadline = true + if v == nil { + empty := "" + patch.Deadline = &empty + } else if s, ok := v.(string); ok { + patch.Deadline = &s + } + } + if v, present := raw["tags"]; present { + tags := []string{} + if arr, ok := v.([]any); ok { + for _, t := range arr { + if s, ok := t.(string); ok { + tags = append(tags, s) + } + } + } + patch.Tags = &tags + } + actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey) + if err := db.UpdateCardWithActor(id, patch, actor); err != nil { + serverError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) + } +} + +// PUT /api/cards/{id}/stickers { stickers: [{emoji,x,y}, ...] } +func handleUpdateCardStickers(db *DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + var body struct { + Stickers []Sticker `json:"stickers"` + } + if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil { + badRequest(w, err.Error()) + return + } + if err := db.UpdateStickers(id, body.Stickers); err != nil { + serverError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) + } +} + +// DELETE /api/cards/{id} +func handleDeleteCard(db *DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey) + if err := db.DeleteCardWithActor(id, actor); err != nil { + serverError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) + } +} + +// POST /api/cards/{id}/move { column_id, ordered_ids } +func handleMoveCard(db *DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + var body struct { + ColumnID string `json:"column_id"` + OrderedIDs []string `json:"ordered_ids"` + } + if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil { + badRequest(w, err.Error()) + return + } + if body.ColumnID == "" { + badRequest(w, "column_id required") + return + } + actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey) + if err := db.MoveCard(id, body.ColumnID, body.OrderedIDs, actor); err != nil { + if strings.Contains(err.Error(), "not found") { + notFound(w, "card not found") + return + } + serverError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) + } +} + +// GET /api/cards/{id}/messages → [CardMessage, ...] +func handleListCardMessages(db *DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + msgs, err := db.ListCardMessages(id) + if err != nil { + serverError(w, err) + return + } + infra.HTTPJSONResponse(w, http.StatusOK, msgs) + } +} + +// POST /api/cards/{id}/messages { body } +func handleCreateCardMessage(db *DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + var body struct { + Body string `json:"body"` + } + if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil { + badRequest(w, err.Error()) + return + } + if strings.TrimSpace(body.Body) == "" { + badRequest(w, "body required") + return + } + actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey) + if actor == "" { + infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "session required"}) + return + } + m, err := db.CreateCardMessage(id, actor, body.Body) + if err != nil { + if strings.Contains(err.Error(), "not found") { + notFound(w, err.Error()) + return + } + serverError(w, err) + return + } + infra.HTTPJSONResponse(w, http.StatusCreated, m) + } +} + +// DELETE /api/cards/{cid}/messages/{mid} +func handleDeleteCardMessage(db *DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + mid := r.PathValue("mid") + actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey) + if actor == "" { + infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "session required"}) + return + } + if err := db.DeleteCardMessage(mid, actor); err != nil { + if strings.Contains(err.Error(), "not found") { + notFound(w, err.Error()) + return + } + serverError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) + } +} + +// POST /api/cards/{id}/duplicate +func handleDuplicateCard(db *DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey) + c, err := db.DuplicateCard(id, actor) + if err != nil { + if strings.Contains(err.Error(), "not found") { + notFound(w, "card not found") + return + } + serverError(w, err) + return + } + infra.HTTPJSONResponse(w, http.StatusCreated, c) + } +} + +// GET /api/cards/{id}/history → [HistoryEntry, ...] +func handleCardHistory(db *DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + entries, err := db.CardHistory(id) + if err != nil { + serverError(w, err) + return + } + infra.HTTPJSONResponse(w, http.StatusOK, entries) + } +} + +// GET /api/trash +func handleListTrash(db *DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + cards, err := db.ListDeletedCards() + if err != nil { + serverError(w, err) + return + } + infra.HTTPJSONResponse(w, http.StatusOK, cards) + } +} + +// POST /api/cards/{id}/restore +func handleRestoreCard(db *DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey) + if err := db.RestoreCardWithActor(id, actor); err != nil { + serverError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) + } +} + +// DELETE /api/cards/{id}/purge +func handlePurgeCard(db *DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if err := db.PurgeCard(id); err != nil { + serverError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) + } +} + +func apiRoutes(db *DB, chatWorkdir string, logger *ChatLogger, internalToken string, flags *FeatureFlags) []infra.Route { + return []infra.Route{ + {Method: "GET", Path: "/api/flags", Handler: handleListFlags(flags)}, + {Method: "POST", Path: "/api/auth/register", Handler: handleRegister(db, flags)}, + {Method: "POST", Path: "/api/auth/login", Handler: handleLogin(db)}, + {Method: "POST", Path: "/api/auth/logout", Handler: handleLogout(db)}, + {Method: "GET", Path: "/api/me", Handler: handleMe(db)}, + {Method: "PATCH", Path: "/api/me", Handler: handlePatchMe(db)}, + {Method: "GET", Path: "/api/users", Handler: handleListUsers(db)}, + {Method: "GET", Path: "/api/board", Handler: handleGetBoard(db)}, + {Method: "POST", Path: "/api/columns", Handler: handleCreateColumn(db)}, + {Method: "POST", Path: "/api/columns/reorder", Handler: handleReorderColumns(db)}, + {Method: "PATCH", Path: "/api/columns/{id}", Handler: handleUpdateColumn(db)}, + {Method: "DELETE", Path: "/api/columns/{id}", Handler: handleDeleteColumn(db)}, + {Method: "POST", Path: "/api/cards", Handler: handleCreateCard(db)}, + {Method: "PATCH", Path: "/api/cards/{id}", Handler: handleUpdateCard(db)}, + {Method: "PUT", Path: "/api/cards/{id}/stickers", Handler: handleUpdateCardStickers(db)}, + {Method: "DELETE", Path: "/api/cards/{id}", Handler: handleDeleteCard(db)}, + {Method: "POST", Path: "/api/cards/{id}/move", Handler: handleMoveCard(db)}, + {Method: "POST", Path: "/api/cards/{id}/duplicate", Handler: handleDuplicateCard(db)}, + {Method: "GET", Path: "/api/cards/{id}/messages", Handler: handleListCardMessages(db)}, + {Method: "POST", Path: "/api/cards/{id}/messages", Handler: handleCreateCardMessage(db)}, + {Method: "DELETE", Path: "/api/cards/{id}/messages/{mid}", Handler: handleDeleteCardMessage(db)}, + {Method: "GET", Path: "/api/cards/{id}/history", Handler: handleCardHistory(db)}, + {Method: "GET", Path: "/api/trash", Handler: handleListTrash(db)}, + {Method: "POST", Path: "/api/cards/{id}/restore", Handler: handleRestoreCard(db)}, + {Method: "DELETE", Path: "/api/cards/{id}/purge", Handler: handlePurgeCard(db)}, + {Method: "POST", Path: "/api/chat", Handler: handleChat(db, chatWorkdir, logger)}, + {Method: "GET", Path: "/api/chat/ws", Handler: handleChatWS(db, chatWorkdir, logger, internalToken)}, + {Method: "POST", Path: "/api/tool/{name}", Handler: handleInternalTool(db, internalToken, logger)}, + {Method: "GET", Path: "/api/metrics", Handler: handleMetrics(db)}, + {Method: "GET", Path: "/api/tags", Handler: handleListTags(db)}, + {Method: "GET", Path: "/api/requesters", Handler: handleListRequesters(db)}, + } +} + +func handleListTags(db *DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + tags, err := db.ListAllTags() + if err != nil { + serverError(w, err) + return + } + infra.HTTPJSONResponse(w, http.StatusOK, tags) + } +} + +func handleListRequesters(db *DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + out, err := db.ListDistinctRequesters() + if err != nil { + serverError(w, err) + return + } + infra.HTTPJSONResponse(w, http.StatusOK, out) + } +} diff --git a/backend/internal_tool.go b/backend/internal_tool.go new file mode 100644 index 0000000..521228e --- /dev/null +++ b/backend/internal_tool.go @@ -0,0 +1,60 @@ +package main + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "io" + "net/http" + + "fn-registry/functions/infra" +) + +const internalTokenHeader = "X-Internal-Token" + +// generateInternalToken returns a 32-byte hex token used by the kanban-mcp +// subprocess to call back into /api/tool/{name}. Generated fresh per process. +func generateInternalToken() string { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + panic("rand.Read: " + err.Error()) + } + return hex.EncodeToString(b) +} + +// handleInternalTool exposes executeTool via HTTP for the MCP subprocess. +// Auth: shared internal token in X-Internal-Token header. Constant-time compare. +func handleInternalTool(db *DB, expectedToken string, logger *ChatLogger) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + got := r.Header.Get(internalTokenHeader) + if subtle.ConstantTimeCompare([]byte(got), []byte(expectedToken)) != 1 { + infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "invalid internal token"}) + return + } + name := r.PathValue("name") + if name == "" { + infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusBadRequest, Code: "bad_request", Message: "tool name required"}) + return + } + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBodyBytes)) + if err != nil { + infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusBadRequest, Code: "bad_request", Message: err.Error()}) + return + } + if len(body) == 0 { + body = []byte("{}") + } + input := json.RawMessage(body) + if err := validateToolName(name); err != nil { + infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusNotFound, Code: "unknown_tool", Message: err.Error()}) + return + } + res := executeTool(db, name, input) + if logger != nil { + logger.Log(name, input, res) + } + // Always 200 — MCP-side maps res.OK to MCP isError. + infra.HTTPJSONResponse(w, http.StatusOK, res) + } +} diff --git a/backend/main.go b/backend/main.go new file mode 100644 index 0000000..4465dc9 --- /dev/null +++ b/backend/main.go @@ -0,0 +1,167 @@ +package main + +import ( + "context" + "embed" + "flag" + "fmt" + "io/fs" + "log" + "net/http" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + "time" + + "fn-registry/functions/infra" +) + +//go:embed all:dist +var frontendDist embed.FS + +func main() { + // Subcommand `kanban mcp` runs as MCP server over stdio (spawned by claude -p). + if len(os.Args) > 1 && os.Args[1] == "mcp" { + if err := runMCPServer(os.Args[2:]); err != nil { + fmt.Fprintf(os.Stderr, "kanban mcp: %v\n", err) + os.Exit(1) + } + return + } + + flags := flag.NewFlagSet("kanban", flag.ExitOnError) + port := flags.Int("port", 8403, "HTTP port") + dbPath := flags.String("db", "operations.db", "SQLite database path") + initialAdmin := flags.String("initial-admin", os.Getenv("KANBAN_INITIAL_ADMIN"), "Bootstrap admin in user:pass form (only if no users yet)") + flagsPath := flags.String("flags", "dev/feature_flags.json", "Feature flags JSON path (missing file → all disabled)") + flags.Parse(os.Args[1:]) + + featureFlags, err := loadFeatureFlags(*flagsPath) + if err != nil { + log.Fatalf("load feature flags: %v", err) + } + for name, fl := range featureFlags.Flags { + log.Printf("feature flag %q enabled=%v", name, fl.Enabled) + } + + db, err := openDB(*dbPath) + if err != nil { + log.Fatalf("open db: %v", err) + } + defer db.Close() + + bootstrapAdmin(db, *initialAdmin) + startSessionCleanup(db) + + internalToken := os.Getenv("KANBAN_INTERNAL_TOKEN") + if internalToken == "" { + internalToken = generateInternalToken() + } + + wd := chatWorkdir(*dbPath) + logger := newChatLogger(filepath.Join(wd, "chat.log")) + log.Printf("chat tool log: %s", logger.path) + mux := infra.HTTPRouter(apiRoutes(db, wd, logger, internalToken, &featureFlags)) + + feHandler := frontendHandler() + if feHandler != nil { + mux.Handle("/", feHandler) + log.Printf("serving frontend from embedded dist/") + } else { + log.Printf("no frontend build found, API-only mode") + } + + authMW := infra.HTTPSessionCookieMiddleware(infra.SessionCookieConfig{ + DB: db.conn, + CookieName: cookieName, + SkipPaths: []string{"/api/auth/", "/api/tool/", "/api/flags", "/health", "/assets/", "/index.html"}, + UserCtxKey: userCtxKey, + }) + + chain := infra.HTTPMiddlewareChain( + infra.HTTPLoggerMiddleware(os.Stdout), + infra.HTTPCORSMiddleware([]string{"*"}, []string{"GET", "POST", "PATCH", "DELETE", "OPTIONS"}), + apiOnlyAuth(authMW), + ) + handler := chain(mux) + + addr := fmt.Sprintf(":%d", *port) + log.Printf("kanban server starting on http://0.0.0.0%s", addr) + log.Printf("database: %s", *dbPath) + + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + if err := infra.HTTPServe(addr, handler, ctx); err != nil { + log.Fatalf("server: %v", err) + } +} + +// apiOnlyAuth applies auth middleware only to /api/* paths so the SPA shell +// can be served without a session (the SPA itself handles login UI). +func apiOnlyAuth(mw infra.Middleware) infra.Middleware { + return func(next http.Handler) http.Handler { + gated := mw(next) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/api/") { + gated.ServeHTTP(w, r) + return + } + next.ServeHTTP(w, r) + }) + } +} + +func bootstrapAdmin(db *DB, spec string) { + spec = strings.TrimSpace(spec) + if spec == "" { + return + } + count, err := db.CountUsers() + if err != nil { + log.Printf("bootstrap admin: count users: %v", err) + return + } + if count > 0 { + return + } + parts := strings.SplitN(spec, ":", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + log.Printf("bootstrap admin: invalid spec, expected user:pass") + return + } + u, err := db.CreateUser(parts[0], parts[1], parts[0]) + if err != nil { + log.Printf("bootstrap admin: %v", err) + return + } + log.Printf("bootstrap admin: created user %q", u.Username) +} + +func startSessionCleanup(db *DB) { + go func() { + t := time.NewTicker(1 * time.Hour) + defer t.Stop() + for range t.C { + if n, err := infra.SessionCleanup(db.conn); err != nil { + log.Printf("session cleanup: %v", err) + } else if n > 0 { + log.Printf("session cleanup: purged %d expired", n) + } + } + }() +} + +func frontendHandler() http.Handler { + sub, err := fs.Sub(frontendDist, "dist") + if err != nil { + return nil + } + entries, _ := fs.ReadDir(sub, ".") + if len(entries) == 0 { + return nil + } + return infra.SPAHandler(sub, "index.html") +} diff --git a/backend/mcp.go b/backend/mcp.go new file mode 100644 index 0000000..a07f394 --- /dev/null +++ b/backend/mcp.go @@ -0,0 +1,302 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "flag" + "fmt" + "io" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "fn-registry/functions/infra" +) + +// runMCPServer is the entry point for the `kanban mcp` subcommand. It runs +// stdio JSON-RPC and forwards each tool call to the kanban backend's +// /api/tool/{name} endpoint, authenticated with a shared internal token. +// +// Required env vars (set by the parent kanban process when generating mcp.json): +// KANBAN_BACKEND_URL — e.g. http://127.0.0.1:8095 +// KANBAN_INTERNAL_TOKEN — token to send in X-Internal-Token header +func runMCPServer(args []string) error { + fs := flag.NewFlagSet("kanban mcp", flag.ContinueOnError) + urlFlag := fs.String("url", os.Getenv("KANBAN_BACKEND_URL"), "kanban backend URL") + tokenFlag := fs.String("token", os.Getenv("KANBAN_INTERNAL_TOKEN"), "internal token") + if err := fs.Parse(args); err != nil { + return err + } + if *urlFlag == "" { + return fmt.Errorf("--url or KANBAN_BACKEND_URL required") + } + if *tokenFlag == "" { + return fmt.Errorf("--token or KANBAN_INTERNAL_TOKEN required") + } + + httpClient := &http.Client{Timeout: 30 * time.Second} + + tools := mcpToolDefs() + handler := func(ctx context.Context, name string, input json.RawMessage) (any, bool, error) { + body := []byte(input) + if len(body) == 0 { + body = []byte("{}") + } + req, err := http.NewRequestWithContext(ctx, "POST", *urlFlag+"/api/tool/"+name, bytes.NewReader(body)) + if err != nil { + return nil, false, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set(internalTokenHeader, *tokenFlag) + resp, err := httpClient.Do(req) + if err != nil { + return nil, false, err + } + defer resp.Body.Close() + buf, err := io.ReadAll(resp.Body) + if err != nil { + return nil, false, err + } + if resp.StatusCode >= 500 { + return nil, false, fmt.Errorf("backend %d: %s", resp.StatusCode, string(buf)) + } + // 4xx and 2xx both serialize as ToolResult JSON. Decode and map. + var tr ToolResult + if err := json.Unmarshal(buf, &tr); err != nil { + // Non-ToolResult body (e.g. unauthorized error envelope from infra). + return string(buf), resp.StatusCode >= 400, nil + } + if !tr.OK { + return tr.Error, true, nil + } + return tr.Result, false, nil + } + + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + return infra.ServeMCP(ctx, infra.MCPServerOpts{ + Name: "kanban", + Version: "1.0.0", + Tools: tools, + Handler: handler, + In: os.Stdin, + Out: os.Stdout, + Logger: os.Stderr, + }) +} + +// mcpToolDefs returns the JSON-Schema definitions for every kanban tool. +// Names match the executeTool dispatch table in tools.go. +func mcpToolDefs() []infra.MCPToolDef { + return []infra.MCPToolDef{ + { + Name: "list_board", + Description: "Lista columnas y tarjetas del tablero. Sin argumentos. Devuelve {columns, cards}.", + InputSchema: rawSchema(map[string]any{"type": "object", "properties": map[string]any{}}), + }, + { + Name: "create_column", + Description: "Crea una columna nueva. Devuelve la columna creada con su id.", + InputSchema: rawSchema(map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{"type": "string", "description": "Nombre de la columna"}, + }, + "required": []string{"name"}, + }), + }, + { + Name: "update_column", + Description: "Modifica una columna existente. Pasa al menos uno: name, location ('board'|'sidebar'), width (200..800 px), wip_limit (0=sin limite), is_done (terminal: cards cuentan como completadas).", + InputSchema: rawSchema(map[string]any{ + "type": "object", + "properties": map[string]any{ + "id": map[string]any{"type": "string"}, + "name": map[string]any{"type": "string"}, + "location": map[string]any{"type": "string", "enum": []string{"board", "sidebar"}}, + "width": map[string]any{"type": "integer"}, + "wip_limit": map[string]any{"type": "integer"}, + "is_done": map[string]any{"type": "boolean"}, + }, + "required": []string{"id"}, + }), + }, + { + Name: "rename_column", + Description: "Alias de update_column con solo {id, name}.", + InputSchema: rawSchema(map[string]any{ + "type": "object", + "properties": map[string]any{ + "id": map[string]any{"type": "string"}, + "name": map[string]any{"type": "string"}, + }, + "required": []string{"id", "name"}, + }), + }, + { + Name: "delete_column", + Description: "Elimina una columna y todas sus tarjetas (las envia a la papelera).", + InputSchema: rawSchema(map[string]any{ + "type": "object", + "properties": map[string]any{ + "id": map[string]any{"type": "string"}, + }, + "required": []string{"id"}, + }), + }, + { + Name: "reorder_columns", + Description: "Reordena columnas. ids es el array completo de columnas en el nuevo orden.", + InputSchema: rawSchema(map[string]any{ + "type": "object", + "properties": map[string]any{ + "ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + }, + "required": []string{"ids"}, + }), + }, + { + Name: "create_card", + Description: "Crea una tarjeta en una columna. column_id y title obligatorios.", + InputSchema: rawSchema(map[string]any{ + "type": "object", + "properties": map[string]any{ + "column_id": map[string]any{"type": "string"}, + "requester": map[string]any{"type": "string"}, + "title": map[string]any{"type": "string"}, + "description": map[string]any{"type": "string"}, + }, + "required": []string{"column_id", "title"}, + }), + }, + { + Name: "update_card", + Description: "Edita campos de una tarjeta. Color: blue|teal|violet|pink|orange|green|yellow|red|''. locked bloquea movimiento. assignee_id null para desasignar.", + InputSchema: rawSchema(map[string]any{ + "type": "object", + "properties": map[string]any{ + "id": map[string]any{"type": "string"}, + "requester": map[string]any{"type": "string"}, + "title": map[string]any{"type": "string"}, + "description": map[string]any{"type": "string"}, + "color": map[string]any{"type": "string"}, + "locked": map[string]any{"type": "boolean"}, + "assignee_id": map[string]any{"type": []string{"string", "null"}}, + }, + "required": []string{"id"}, + }), + }, + { + Name: "delete_card", + Description: "Envia una tarjeta a la papelera.", + InputSchema: rawSchema(map[string]any{ + "type": "object", + "properties": map[string]any{ + "id": map[string]any{"type": "string"}, + }, + "required": []string{"id"}, + }), + }, + { + Name: "move_card", + Description: "Mueve una tarjeta a otra columna. Si omites ordered_ids, se anade al final.", + InputSchema: rawSchema(map[string]any{ + "type": "object", + "properties": map[string]any{ + "id": map[string]any{"type": "string"}, + "column_id": map[string]any{"type": "string"}, + "ordered_ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + }, + "required": []string{"id", "column_id"}, + }), + }, + { + Name: "card_history", + Description: "Devuelve el historial de cambios de una tarjeta.", + InputSchema: rawSchema(map[string]any{ + "type": "object", + "properties": map[string]any{ + "id": map[string]any{"type": "string"}, + }, + "required": []string{"id"}, + }), + }, + { + Name: "find_cards", + Description: "Busca tarjetas. query (texto en title/description/requester), column_id (filtra por columna), requester (filtra por solicitante).", + InputSchema: rawSchema(map[string]any{ + "type": "object", + "properties": map[string]any{ + "query": map[string]any{"type": "string"}, + "column_id": map[string]any{"type": "string"}, + "requester": map[string]any{"type": "string"}, + }, + }), + }, + { + Name: "list_users", + Description: "Lista usuarios disponibles para asignar tarjetas.", + InputSchema: rawSchema(map[string]any{"type": "object", "properties": map[string]any{}}), + }, + { + Name: "assign_card", + Description: "Asigna o desasigna una tarjeta. assignee_id null para desasignar.", + InputSchema: rawSchema(map[string]any{ + "type": "object", + "properties": map[string]any{ + "id": map[string]any{"type": "string"}, + "assignee_id": map[string]any{"type": []string{"string", "null"}}, + }, + "required": []string{"id"}, + }), + }, + } +} + +func rawSchema(s map[string]any) json.RawMessage { + b, err := json.Marshal(s) + if err != nil { + panic(err) + } + return b +} + +// writeMCPConfig writes a temporary mcp.json that points to this binary's +// `mcp` subcommand with the given URL and token. Returns the absolute path of +// the file created. Caller is responsible for removing it. +func writeMCPConfig(binPath, backendURL, token string) (string, error) { + cfg := map[string]any{ + "mcpServers": map[string]any{ + "kanban": map[string]any{ + "command": binPath, + "args": []string{"mcp"}, + "env": map[string]string{ + "KANBAN_BACKEND_URL": backendURL, + "KANBAN_INTERNAL_TOKEN": token, + }, + }, + }, + } + b, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return "", err + } + f, err := os.CreateTemp("", "kanban-mcp-*.json") + if err != nil { + return "", err + } + if _, err := f.Write(b); err != nil { + f.Close() + os.Remove(f.Name()) + return "", err + } + if err := f.Close(); err != nil { + os.Remove(f.Name()) + return "", err + } + return f.Name(), nil +} diff --git a/backend/metrics.go b/backend/metrics.go new file mode 100644 index 0000000..87ae881 --- /dev/null +++ b/backend/metrics.go @@ -0,0 +1,603 @@ +package main + +import ( + "net/http" + "sort" + "strings" + "time" + + "fn-registry/functions/core" + "fn-registry/functions/datascience" + "fn-registry/functions/infra" +) + +type DurationStats = datascience.DurationStats + +type Metrics struct { + Range DateRange `json:"range"` + Totals Totals `json:"totals"` + ByColumn []ColumnCount `json:"by_column"` + ThroughputDaily []DailyCount `json:"throughput_daily"` + CreatedDaily []DailyCount `json:"created_daily"` + LeadTime DurationStats `json:"lead_time"` + CycleTimeColumn []ColumnDuration `json:"cycle_time_per_column"` + TopAssignees []AssigneeStat `json:"top_assignees"` + TopRequesters []RequesterStat `json:"top_requesters"` + MovementsByUser []MovementStat `json:"movements_by_user"` + LockTotalMs int64 `json:"lock_total_ms"` + LockActiveCount int `json:"lock_active_count"` + CumulativeFlow []CumulativePoint `json:"cumulative_flow"` +} + +type CumulativePoint struct { + Date string `json:"date"` + Total int `json:"total"` + Done int `json:"done"` +} + +type DateRange struct { + From string `json:"from"` + To string `json:"to"` +} + +type Totals struct { + Cards int `json:"cards"` + CardsCompleted int `json:"cards_completed_in_range"` + CardsCreated int `json:"cards_created_in_range"` + CardsActive int `json:"cards_active"` + CardsDone int `json:"cards_done"` + Columns int `json:"columns"` + Users int `json:"users"` + ActiveLocks int `json:"active_locks"` +} + +type ColumnCount struct { + ColumnID string `json:"column_id"` + Name string `json:"name"` + IsDone bool `json:"is_done"` + Count int `json:"count"` +} + +type DailyCount struct { + Date string `json:"date"` + Count int `json:"count"` +} + +type ColumnDuration struct { + ColumnID string `json:"column_id"` + Name string `json:"name"` + IsDone bool `json:"is_done"` + Stats DurationStats `json:"stats"` +} + +type AssigneeStat struct { + UserID string `json:"user_id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + Active int `json:"active"` + Completed int `json:"completed_in_range"` +} + +type RequesterStat struct { + Requester string `json:"requester"` + Total int `json:"total"` + Active int `json:"active"` + Completed int `json:"completed_in_range"` +} + +type MovementStat struct { + UserID string `json:"user_id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + Moves int `json:"moves"` +} + +func computeStats(durations []int64) DurationStats { + return datascience.DurationStatsFrom(durations) +} + +func parseDateOrDefault(s string, dflt time.Time) time.Time { + return core.ParseDateOrDefault(s, dflt, false) +} + +func parseEndDateOrDefault(s string, dflt time.Time) time.Time { + return core.ParseDateOrDefault(s, dflt, true) +} + +// GET /api/metrics?from=YYYY-MM-DD&to=YYYY-MM-DD&assignee_id=...&requester=... +func handleMetrics(db *DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + now := time.Now().UTC() + from := parseDateOrDefault(r.URL.Query().Get("from"), now.AddDate(0, 0, -30)) + to := parseEndDateOrDefault(r.URL.Query().Get("to"), now) + assignee := r.URL.Query().Get("assignee_id") + requester := r.URL.Query().Get("requester") + tagsRaw := r.URL.Query().Get("tags") + var tags []string + if tagsRaw != "" { + for _, t := range strings.Split(tagsRaw, ",") { + if t = strings.TrimSpace(t); t != "" { + tags = append(tags, t) + } + } + } + + m, err := computeMetrics(db, from, to, assignee, requester, tags) + if err != nil { + serverError(w, err) + return + } + infra.HTTPJSONResponse(w, http.StatusOK, m) + } +} + +func computeMetrics(db *DB, from, to time.Time, assignee, requester string, tags []string) (*Metrics, error) { + fromStr := from.Format(time.RFC3339Nano) + toStr := to.Format(time.RFC3339Nano) + + m := &Metrics{ + Range: DateRange{From: from.Format("2006-01-02"), To: to.Format("2006-01-02")}, + ByColumn: []ColumnCount{}, + ThroughputDaily: []DailyCount{}, + CreatedDaily: []DailyCount{}, + CycleTimeColumn: []ColumnDuration{}, + TopAssignees: []AssigneeStat{}, + TopRequesters: []RequesterStat{}, + MovementsByUser: []MovementStat{}, + CumulativeFlow: []CumulativePoint{}, + } + + cardWhere := "WHERE deleted_at IS NULL" + args := []any{} + if assignee != "" { + cardWhere += " AND assignee_id=?" + args = append(args, assignee) + } + if requester != "" { + cardWhere += " AND requester=?" + args = append(args, requester) + } + for _, t := range tags { + cardWhere += " AND tags LIKE ?" + args = append(args, `%"`+t+`"%`) + } + + if err := db.conn.QueryRow(`SELECT COUNT(*) FROM cards `+cardWhere, args...).Scan(&m.Totals.Cards); err != nil { + return nil, err + } + completedArgs := append([]any{fromStr, toStr}, args...) + if err := db.conn.QueryRow( + `SELECT COUNT(*) FROM cards `+cardWhere+` AND completed_at IS NOT NULL AND completed_at>=? AND completed_at<=?`, + append(args, fromStr, toStr)..., + ).Scan(&m.Totals.CardsCompleted); err != nil { + return nil, err + } + if err := db.conn.QueryRow( + `SELECT COUNT(*) FROM cards `+cardWhere+` AND created_at>=? AND created_at<=?`, + append(args, fromStr, toStr)..., + ).Scan(&m.Totals.CardsCreated); err != nil { + return nil, err + } + if err := db.conn.QueryRow( + `SELECT COUNT(*) FROM cards `+cardWhere+` AND (completed_at IS NULL OR completed_at='')`, + args..., + ).Scan(&m.Totals.CardsActive); err != nil { + return nil, err + } + if err := db.conn.QueryRow( + `SELECT COUNT(*) FROM cards `+cardWhere+` AND completed_at IS NOT NULL AND completed_at!=''`, + args..., + ).Scan(&m.Totals.CardsDone); err != nil { + return nil, err + } + _ = completedArgs + + if err := db.conn.QueryRow(`SELECT COUNT(*) FROM columns`).Scan(&m.Totals.Columns); err != nil { + return nil, err + } + if err := db.conn.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&m.Totals.Users); err != nil { + return nil, err + } + lockActiveQ := `SELECT COUNT(*) FROM card_lock_history h JOIN cards c ON c.id=h.card_id WHERE h.unlocked_at IS NULL AND c.deleted_at IS NULL` + if assignee != "" { + lockActiveQ += ` AND c.assignee_id=?` + } + if requester != "" { + lockActiveQ += ` AND c.requester=?` + } + if err := db.conn.QueryRow(lockActiveQ, args...).Scan(&m.Totals.ActiveLocks); err != nil { + return nil, err + } + + // By column. + rows, err := db.conn.Query( + `SELECT col.id, col.name, col.is_done, COUNT(c.id) + FROM columns col + LEFT JOIN cards c ON c.column_id=col.id`+ + condFromCard(assignee, requester, "c", "WHERE")+ + ` GROUP BY col.id ORDER BY col.position`, + colArgs(assignee, requester)..., + ) + if err != nil { + return nil, err + } + for rows.Next() { + var cc ColumnCount + var isDone int + if err := rows.Scan(&cc.ColumnID, &cc.Name, &isDone, &cc.Count); err != nil { + rows.Close() + return nil, err + } + cc.IsDone = isDone != 0 + m.ByColumn = append(m.ByColumn, cc) + } + rows.Close() + + // Throughput daily (completed_at within range). + m.ThroughputDaily, err = dailyBucket(db, "completed_at", fromStr, toStr, assignee, requester, true) + if err != nil { + return nil, err + } + m.CreatedDaily, err = dailyBucket(db, "created_at", fromStr, toStr, assignee, requester, false) + if err != nil { + return nil, err + } + + // Lead time (cards completed in range, completed_at - created_at). + leadDurs, err := collectDurations(db, + `SELECT (julianday(completed_at) - julianday(created_at)) * 86400000 FROM cards `+ + cardWhere+` AND completed_at IS NOT NULL AND completed_at>=? AND completed_at<=?`, + append(args, fromStr, toStr)..., + ) + if err != nil { + return nil, err + } + m.LeadTime = computeStats(leadDurs) + + // Cycle time per column. + colRows, err := db.conn.Query(`SELECT id, name, is_done FROM columns ORDER BY position`) + if err != nil { + return nil, err + } + type colInfo struct { + id, name string + isDone bool + } + var cols []colInfo + for colRows.Next() { + var ci colInfo + var d int + if err := colRows.Scan(&ci.id, &ci.name, &d); err != nil { + colRows.Close() + return nil, err + } + ci.isDone = d != 0 + cols = append(cols, ci) + } + colRows.Close() + + now := time.Now().UTC() + cap := to + if now.Before(cap) { + cap = now + } + capStr := cap.Format(time.RFC3339Nano) + for _, ci := range cols { + histArgs := []any{ci.id, fromStr, toStr} + histQ := `SELECT (julianday(COALESCE(h.exited_at, ?)) - julianday(h.entered_at)) * 86400000 + FROM card_column_history h JOIN cards c ON c.id=h.card_id + WHERE h.column_id=? AND h.entered_at>=? AND h.entered_at<=?` + histArgs = append([]any{capStr}, histArgs...) + if assignee != "" { + histQ += ` AND c.assignee_id=?` + histArgs = append(histArgs, assignee) + } + if requester != "" { + histQ += ` AND c.requester=?` + histArgs = append(histArgs, requester) + } + durs, err := collectDurations(db, histQ, histArgs...) + if err != nil { + return nil, err + } + m.CycleTimeColumn = append(m.CycleTimeColumn, ColumnDuration{ + ColumnID: ci.id, Name: ci.name, IsDone: ci.isDone, + Stats: computeStats(durs), + }) + } + + // Top assignees. + asRows, err := db.conn.Query( + `SELECT u.id, u.username, u.display_name, + SUM(CASE WHEN c.completed_at IS NULL OR c.completed_at='' THEN 1 ELSE 0 END) as active, + SUM(CASE WHEN c.completed_at IS NOT NULL AND c.completed_at>=? AND c.completed_at<=? THEN 1 ELSE 0 END) as completed + FROM users u + LEFT JOIN cards c ON c.assignee_id=u.id` + cardJoinFilter(requester) + + ` GROUP BY u.id ORDER BY completed DESC, active DESC`, + topAssigneeArgs(fromStr, toStr, requester)..., + ) + if err != nil { + return nil, err + } + for asRows.Next() { + var s AssigneeStat + if err := asRows.Scan(&s.UserID, &s.Username, &s.DisplayName, &s.Active, &s.Completed); err != nil { + asRows.Close() + return nil, err + } + m.TopAssignees = append(m.TopAssignees, s) + } + asRows.Close() + + // Top requesters. + reqRows, err := db.conn.Query( + `SELECT requester, + COUNT(*) as total, + SUM(CASE WHEN completed_at IS NULL OR completed_at='' THEN 1 ELSE 0 END) as active, + SUM(CASE WHEN completed_at IS NOT NULL AND completed_at>=? AND completed_at<=? THEN 1 ELSE 0 END) as completed + FROM cards + WHERE deleted_at IS NULL AND requester != ''`+ + condFromCard(assignee, "", "", "AND")+ + ` GROUP BY requester ORDER BY total DESC LIMIT 10`, + topReqArgs(fromStr, toStr, assignee)..., + ) + if err != nil { + return nil, err + } + for reqRows.Next() { + var s RequesterStat + if err := reqRows.Scan(&s.Requester, &s.Total, &s.Active, &s.Completed); err != nil { + reqRows.Close() + return nil, err + } + m.TopRequesters = append(m.TopRequesters, s) + } + reqRows.Close() + + // Movements by user. + mvRows, err := db.conn.Query( + `SELECT u.id, u.username, u.display_name, COUNT(h.id) as moves + FROM users u + LEFT JOIN card_column_history h ON h.actor_id=u.id AND h.entered_at>=? AND h.entered_at<=? + GROUP BY u.id ORDER BY moves DESC`, + fromStr, toStr, + ) + if err != nil { + return nil, err + } + for mvRows.Next() { + var s MovementStat + if err := mvRows.Scan(&s.UserID, &s.Username, &s.DisplayName, &s.Moves); err != nil { + mvRows.Close() + return nil, err + } + m.MovementsByUser = append(m.MovementsByUser, s) + } + mvRows.Close() + + // Lock total in range. + var lockMs float64 + if err := db.conn.QueryRow( + `SELECT COALESCE(SUM( + (julianday(COALESCE(h.unlocked_at, ?)) - julianday(h.locked_at)) * 86400000 + ), 0) FROM card_lock_history h JOIN cards c ON c.id=h.card_id + WHERE h.locked_at>=? AND h.locked_at<=?`+condFromCard(assignee, requester, "c", "AND"), + append([]any{toStr, fromStr, toStr}, colArgs(assignee, requester)...)..., + ).Scan(&lockMs); err != nil { + return nil, err + } + m.LockTotalMs = int64(lockMs) + + // Cumulative flow: walk daily from→to, count cards created<=day and done<=day. + cfd, err := computeCumulativeFlow(db, from, to, assignee, requester) + if err != nil { + return nil, err + } + m.CumulativeFlow = cfd + + return m, nil +} + +func computeCumulativeFlow(db *DB, from, to time.Time, assignee, requester string) ([]CumulativePoint, error) { + creates := map[string]int{} + dones := map[string]int{} + + cardWhere := "WHERE deleted_at IS NULL" + args := []any{} + if assignee != "" { + cardWhere += " AND assignee_id=?" + args = append(args, assignee) + } + if requester != "" { + cardWhere += " AND requester=?" + args = append(args, requester) + } + + rows, err := db.conn.Query(`SELECT substr(created_at,1,10), COUNT(*) FROM cards `+cardWhere+` GROUP BY substr(created_at,1,10)`, args...) + if err != nil { + return nil, err + } + for rows.Next() { + var d string + var n int + if err := rows.Scan(&d, &n); err != nil { + rows.Close() + return nil, err + } + creates[d] = n + } + rows.Close() + + rows, err = db.conn.Query(`SELECT substr(completed_at,1,10), COUNT(*) FROM cards `+cardWhere+` AND completed_at IS NOT NULL AND completed_at != '' GROUP BY substr(completed_at,1,10)`, args...) + if err != nil { + return nil, err + } + for rows.Next() { + var d string + var n int + if err := rows.Scan(&d, &n); err != nil { + rows.Close() + return nil, err + } + dones[d] = n + } + rows.Close() + + out := []CumulativePoint{} + totalAcc := 0 + doneAcc := 0 + day := from + end := to + if end.Before(day) { + return out, nil + } + for d := day; !d.After(end); d = d.AddDate(0, 0, 1) { + ds := d.Format("2006-01-02") + // Sum all creates with key <= ds, all dones with key <= ds. + // Optimize: track keys already accounted; here we just do once per loop using map sums. + _ = ds + } + // Simpler: collect and sort all create/done dates, sweep. + type ev struct { + date string + creates int + dones int + } + all := map[string]*ev{} + for d, n := range creates { + all[d] = &ev{date: d, creates: n} + } + for d, n := range dones { + if e, ok := all[d]; ok { + e.dones = n + } else { + all[d] = &ev{date: d, dones: n} + } + } + dates := make([]string, 0, len(all)) + for d := range all { + dates = append(dates, d) + } + sort.Strings(dates) + + // Accumulate up to `from` first. + fromS := from.Format("2006-01-02") + idx := 0 + for idx < len(dates) && dates[idx] < fromS { + totalAcc += all[dates[idx]].creates + doneAcc += all[dates[idx]].dones + idx++ + } + for d := from; !d.After(to); d = d.AddDate(0, 0, 1) { + ds := d.Format("2006-01-02") + for idx < len(dates) && dates[idx] <= ds { + totalAcc += all[dates[idx]].creates + doneAcc += all[dates[idx]].dones + idx++ + } + out = append(out, CumulativePoint{Date: ds, Total: totalAcc, Done: doneAcc}) + } + return out, nil +} + +func condFromCard(assignee, requester, alias, leadKw string) string { + pref := alias + if pref != "" { + pref += "." + } + out := "" + if assignee != "" { + out += " " + leadKw + " " + pref + "assignee_id=?" + leadKw = "AND" + } + if requester != "" { + out += " " + leadKw + " " + pref + "requester=?" + } + return out +} + +func colArgs(assignee, requester string) []any { + args := []any{} + if assignee != "" { + args = append(args, assignee) + } + if requester != "" { + args = append(args, requester) + } + return args +} + +func cardJoinFilter(requester string) string { + if requester != "" { + return " AND c.requester=?" + } + return "" +} + +func topAssigneeArgs(fromStr, toStr, requester string) []any { + args := []any{fromStr, toStr} + if requester != "" { + args = append(args, requester) + } + return args +} + +func topReqArgs(fromStr, toStr, assignee string) []any { + args := []any{fromStr, toStr} + if assignee != "" { + args = append(args, assignee) + } + return args +} + +func collectDurations(db *DB, query string, args ...any) ([]int64, error) { + rows, err := db.conn.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + out := []int64{} + for rows.Next() { + var v float64 + if err := rows.Scan(&v); err != nil { + return nil, err + } + if v < 0 { + v = 0 + } + out = append(out, int64(v)) + } + return out, rows.Err() +} + +func dailyBucket(db *DB, dateCol, fromStr, toStr, assignee, requester string, requireNonNull bool) ([]DailyCount, error) { + cardWhere := "deleted_at IS NULL" + if requireNonNull { + cardWhere += " AND " + dateCol + " IS NOT NULL AND " + dateCol + " != ''" + } + cardWhere += " AND " + dateCol + ">=? AND " + dateCol + "<=?" + args := []any{fromStr, toStr} + if assignee != "" { + cardWhere += " AND assignee_id=?" + args = append(args, assignee) + } + if requester != "" { + cardWhere += " AND requester=?" + args = append(args, requester) + } + q := `SELECT substr(` + dateCol + `, 1, 10) as d, COUNT(*) FROM cards WHERE ` + cardWhere + ` GROUP BY d ORDER BY d` + rows, err := db.conn.Query(q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + out := []DailyCount{} + for rows.Next() { + var dc DailyCount + if err := rows.Scan(&dc.Date, &dc.Count); err != nil { + return nil, err + } + out = append(out, dc) + } + return out, rows.Err() +} diff --git a/backend/migrations/001_init.sql b/backend/migrations/001_init.sql new file mode 100644 index 0000000..695f394 --- /dev/null +++ b/backend/migrations/001_init.sql @@ -0,0 +1,51 @@ +CREATE TABLE IF NOT EXISTS columns ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + position INTEGER NOT NULL DEFAULT 0, + location TEXT NOT NULL DEFAULT 'board' CHECK(location IN ('board','sidebar')), + width INTEGER NOT NULL DEFAULT 300, + wip_limit INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS cards ( + id TEXT PRIMARY KEY, + requester TEXT NOT NULL DEFAULT '', + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + color TEXT NOT NULL DEFAULT '', + column_id TEXT NOT NULL REFERENCES columns(id) ON DELETE CASCADE, + position INTEGER NOT NULL DEFAULT 0, + locked INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS card_column_history ( + id TEXT PRIMARY KEY, + card_id TEXT NOT NULL REFERENCES cards(id) ON DELETE CASCADE, + column_id TEXT NOT NULL, + entered_at TEXT NOT NULL, + exited_at TEXT +); + +CREATE TABLE IF NOT EXISTS card_lock_history ( + id TEXT PRIMARY KEY, + card_id TEXT NOT NULL REFERENCES cards(id) ON DELETE CASCADE, + locked_at TEXT NOT NULL, + unlocked_at TEXT +); + +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + display_name TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_cards_column ON cards(column_id); +CREATE INDEX IF NOT EXISTS idx_cards_position ON cards(column_id, position); +CREATE INDEX IF NOT EXISTS idx_history_card ON card_column_history(card_id); +CREATE INDEX IF NOT EXISTS idx_columns_position ON columns(position); +CREATE INDEX IF NOT EXISTS idx_lock_history_card ON card_lock_history(card_id); diff --git a/backend/migrations/002_add_stickers.sql b/backend/migrations/002_add_stickers.sql new file mode 100644 index 0000000..9932db0 --- /dev/null +++ b/backend/migrations/002_add_stickers.sql @@ -0,0 +1,4 @@ +-- Add stickers column to cards. Idempotent ALTER pattern in db.go ensureColumns. +-- Stickers persist as JSON array: [{"emoji":"🔥","x":0.5,"y":0.5}, ...] +-- x, y in [0, 1] relative to card dimensions for resize survival. +ALTER TABLE cards ADD COLUMN stickers TEXT NOT NULL DEFAULT '[]'; diff --git a/backend/migrations/003_columns_extras.sql b/backend/migrations/003_columns_extras.sql new file mode 100644 index 0000000..7fdf035 --- /dev/null +++ b/backend/migrations/003_columns_extras.sql @@ -0,0 +1,6 @@ +-- Columnas extra de `columns` (location, width, wip_limit, is_done). +-- Antes vivian en ensureColumns Go. Reextraidas a migration por consistencia. +ALTER TABLE columns ADD COLUMN location TEXT NOT NULL DEFAULT 'board'; +ALTER TABLE columns ADD COLUMN width INTEGER NOT NULL DEFAULT 300; +ALTER TABLE columns ADD COLUMN wip_limit INTEGER NOT NULL DEFAULT 0; +ALTER TABLE columns ADD COLUMN is_done INTEGER NOT NULL DEFAULT 0; diff --git a/backend/migrations/004_cards_extras.sql b/backend/migrations/004_cards_extras.sql new file mode 100644 index 0000000..e480392 --- /dev/null +++ b/backend/migrations/004_cards_extras.sql @@ -0,0 +1,9 @@ +-- Columnas extra de `cards` (color, locked, assignee_id, completed_at, deleted_at, tags). +-- Antes vivian en ensureColumns Go. La columna stickers va aparte en 002. +ALTER TABLE cards ADD COLUMN color TEXT NOT NULL DEFAULT ''; +ALTER TABLE cards ADD COLUMN locked INTEGER NOT NULL DEFAULT 0; +ALTER TABLE cards ADD COLUMN assignee_id TEXT; +ALTER TABLE cards ADD COLUMN completed_at TEXT; +ALTER TABLE cards ADD COLUMN deleted_at TEXT; +ALTER TABLE cards ADD COLUMN tags TEXT NOT NULL DEFAULT '[]'; +CREATE INDEX IF NOT EXISTS idx_cards_assignee ON cards(assignee_id); diff --git a/backend/migrations/005_history_actor.sql b/backend/migrations/005_history_actor.sql new file mode 100644 index 0000000..e60be65 --- /dev/null +++ b/backend/migrations/005_history_actor.sql @@ -0,0 +1,3 @@ +-- actor_id en histories (quien movió la card / quien bloqueó). +ALTER TABLE card_column_history ADD COLUMN actor_id TEXT; +ALTER TABLE card_lock_history ADD COLUMN actor_id TEXT; diff --git a/backend/migrations/006_user_color.sql b/backend/migrations/006_user_color.sql new file mode 100644 index 0000000..25820bc --- /dev/null +++ b/backend/migrations/006_user_color.sql @@ -0,0 +1,2 @@ +-- Color del avatar del usuario (Mantine color name o '#rrggbb' personalizado). +ALTER TABLE users ADD COLUMN color TEXT NOT NULL DEFAULT ''; diff --git a/backend/migrations/007_card_events.sql b/backend/migrations/007_card_events.sql new file mode 100644 index 0000000..43fa7e5 --- /dev/null +++ b/backend/migrations/007_card_events.sql @@ -0,0 +1,11 @@ +-- Eventos cronologicos por card. Complementa column_history (moves) y lock_history (locks). +-- Captura: created, assigned, unassigned, title_changed, description_changed, color_changed, tags_changed. +CREATE TABLE IF NOT EXISTS card_events ( + id TEXT PRIMARY KEY, + card_id TEXT NOT NULL REFERENCES cards(id) ON DELETE CASCADE, + kind TEXT NOT NULL, + actor_id TEXT, + payload TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_card_events_card ON card_events(card_id, created_at); diff --git a/backend/migrations/008_card_seq_num.sql b/backend/migrations/008_card_seq_num.sql new file mode 100644 index 0000000..7ab8f6d --- /dev/null +++ b/backend/migrations/008_card_seq_num.sql @@ -0,0 +1,7 @@ +-- ID secuencial humano por card. Distinto del id hex (PK interna). +-- Backfill por orden de creacion. +ALTER TABLE cards ADD COLUMN seq_num INTEGER NOT NULL DEFAULT 0; +UPDATE cards SET seq_num = ( + SELECT COUNT(*) FROM cards c2 WHERE c2.created_at <= cards.created_at +) WHERE seq_num = 0; +CREATE UNIQUE INDEX IF NOT EXISTS idx_cards_seq_num ON cards(seq_num) WHERE seq_num > 0; diff --git a/backend/migrations/009_card_deadline.sql b/backend/migrations/009_card_deadline.sql new file mode 100644 index 0000000..e54001d --- /dev/null +++ b/backend/migrations/009_card_deadline.sql @@ -0,0 +1,4 @@ +-- Deadline opcional por card. Fecha RFC3339 (precision dia o instante). +-- NULL = sin deadline (default). El frontend muestra countdown hasta la fecha. +ALTER TABLE cards ADD COLUMN deadline TEXT; +CREATE INDEX IF NOT EXISTS idx_cards_deadline ON cards(deadline) WHERE deadline IS NOT NULL; diff --git a/backend/migrations/010_card_messages.sql b/backend/migrations/010_card_messages.sql new file mode 100644 index 0000000..d9f0929 --- /dev/null +++ b/backend/migrations/010_card_messages.sql @@ -0,0 +1,14 @@ +-- Per-card chat messages (human-to-human comments). +-- Distinct from card_events (which records system events like title_changed) +-- and from /api/chat (which is the board-level LLM chat). + +CREATE TABLE IF NOT EXISTS card_messages ( + id TEXT PRIMARY KEY, + card_id TEXT NOT NULL, + author_id TEXT, + body TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY (card_id) REFERENCES cards(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_card_messages_card ON card_messages(card_id, created_at); diff --git a/backend/stickers_test.go b/backend/stickers_test.go new file mode 100644 index 0000000..43587a6 --- /dev/null +++ b/backend/stickers_test.go @@ -0,0 +1,94 @@ +package main + +import ( + "encoding/json" + "testing" +) + +func TestUpdateStickers_PersistsAndRoundTrips(t *testing.T) { + db := setupTestDB(t) + col := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "X"})).Result.(*Column) + card := executeTool(db, "create_card", mustJSON(t, map[string]string{"column_id": col.ID, "title": "T"})).Result.(*Card) + + if card.Stickers == nil || len(card.Stickers) != 0 { + t.Fatalf("expected empty stickers on new card, got %+v", card.Stickers) + } + + stickers := []Sticker{ + {Emoji: "🔥", X: 0.25, Y: 0.5}, + {Emoji: "✅", X: 0.9, Y: 0.1}, + } + if err := db.UpdateStickers(card.ID, stickers); err != nil { + t.Fatalf("UpdateStickers: %v", err) + } + + cards, err := db.ListCardsWithTime() + if err != nil { + t.Fatalf("ListCardsWithTime: %v", err) + } + if len(cards) != 1 { + t.Fatalf("expected 1 card, got %d", len(cards)) + } + got := cards[0].Stickers + if len(got) != 2 || got[0].Emoji != "🔥" || got[1].Emoji != "✅" { + t.Fatalf("sticker round-trip failed: %+v", got) + } + if got[0].X != 0.25 || got[0].Y != 0.5 { + t.Fatalf("coords lost: %+v", got[0]) + } +} + +func TestUpdateStickers_ClampAndDropEmpty(t *testing.T) { + db := setupTestDB(t) + col := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "X"})).Result.(*Column) + card := executeTool(db, "create_card", mustJSON(t, map[string]string{"column_id": col.ID, "title": "T"})).Result.(*Card) + + in := []Sticker{ + {Emoji: " 🚀 ", X: -0.5, Y: 1.5}, + {Emoji: "", X: 0.5, Y: 0.5}, + {Emoji: "💀", X: 0.3, Y: 0.7}, + } + if err := db.UpdateStickers(card.ID, in); err != nil { + t.Fatalf("UpdateStickers: %v", err) + } + cards, _ := db.ListCardsWithTime() + got := cards[0].Stickers + if len(got) != 2 { + t.Fatalf("expected empty emoji dropped, got %+v", got) + } + if got[0].Emoji != "🚀" || got[0].X != 0 || got[0].Y != 1 { + t.Fatalf("clamp failed: %+v", got[0]) + } + if got[1].Emoji != "💀" { + t.Fatalf("expected 💀 second, got %+v", got[1]) + } +} + +func TestUpdateStickers_OverwriteAndClear(t *testing.T) { + db := setupTestDB(t) + col := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "X"})).Result.(*Column) + card := executeTool(db, "create_card", mustJSON(t, map[string]string{"column_id": col.ID, "title": "T"})).Result.(*Card) + + if err := db.UpdateStickers(card.ID, []Sticker{{Emoji: "🔥", X: 0.5, Y: 0.5}}); err != nil { + t.Fatalf("set: %v", err) + } + if err := db.UpdateStickers(card.ID, []Sticker{}); err != nil { + t.Fatalf("clear: %v", err) + } + cards, _ := db.ListCardsWithTime() + if len(cards[0].Stickers) != 0 { + t.Fatalf("expected cleared, got %+v", cards[0].Stickers) + } +} + +func TestSticker_JSONShape(t *testing.T) { + s := Sticker{Emoji: "🎯", X: 0.1, Y: 0.2} + b, err := json.Marshal(s) + if err != nil { + t.Fatalf("marshal: %v", err) + } + want := `{"emoji":"🎯","x":0.1,"y":0.2}` + if string(b) != want { + t.Fatalf("got %s want %s", b, want) + } +} diff --git a/backend/tools.go b/backend/tools.go new file mode 100644 index 0000000..68d6896 --- /dev/null +++ b/backend/tools.go @@ -0,0 +1,355 @@ +package main + +import ( + "encoding/json" + "fmt" + "sort" + "strings" +) + +// ToolResult is the uniform shape returned to the chat loop after a tool call. +type ToolResult struct { + OK bool `json:"ok"` + Result any `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +func okResult(v any) ToolResult { return ToolResult{OK: true, Result: v} } +func errResult(err error) ToolResult { return ToolResult{OK: false, Error: err.Error()} } +func errMsg(msg string) ToolResult { return ToolResult{OK: false, Error: msg} } + +// executeTool dispatches a tool by name with raw JSON input and returns a ToolResult. +// Tools that mutate the board return ok=true on success; read-only tools include their data in result. +func executeTool(db *DB, name string, input json.RawMessage) ToolResult { + switch name { + case "list_board": + return toolListBoard(db) + case "create_column": + return toolCreateColumn(db, input) + case "update_column": + return toolUpdateColumn(db, input) + case "rename_column": // alias for backwards compat + return toolUpdateColumn(db, input) + case "delete_column": + return toolDeleteColumn(db, input) + case "reorder_columns": + return toolReorderColumns(db, input) + case "create_card": + return toolCreateCard(db, input) + case "update_card": + return toolUpdateCard(db, input) + case "delete_card": + return toolDeleteCard(db, input) + case "move_card": + return toolMoveCard(db, input) + case "card_history": + return toolCardHistory(db, input) + case "find_cards": + return toolFindCards(db, input) + case "list_users": + return toolListUsers(db) + case "assign_card": + return toolAssignCard(db, input) + default: + return errMsg("unknown tool: " + name) + } +} + +// toolMutates reports whether a successful invocation modifies the board state. +func toolMutates(name string) bool { + switch name { + case "create_column", "update_column", "rename_column", "delete_column", "reorder_columns", + "create_card", "update_card", "delete_card", "move_card", "assign_card": + return true + } + return false +} + +func toolListBoard(db *DB) ToolResult { + cols, err := db.ListColumns() + if err != nil { + return errResult(err) + } + cards, err := db.ListCardsWithTime() + if err != nil { + return errResult(err) + } + return okResult(map[string]any{"columns": cols, "cards": cards}) +} + +func toolCreateColumn(db *DB, input json.RawMessage) ToolResult { + var in struct{ Name string `json:"name"` } + if err := json.Unmarshal(input, &in); err != nil { + return errResult(err) + } + if strings.TrimSpace(in.Name) == "" { + return errMsg("name required") + } + c, err := db.CreateColumn(in.Name) + if err != nil { + return errResult(err) + } + return okResult(c) +} + +func toolUpdateColumn(db *DB, input json.RawMessage) ToolResult { + var in struct { + ID string `json:"id"` + Name *string `json:"name"` + Location *string `json:"location"` + Width *int `json:"width"` + WIPLimit *int `json:"wip_limit"` + IsDone *bool `json:"is_done"` + } + if err := json.Unmarshal(input, &in); err != nil { + return errResult(err) + } + if in.ID == "" { + return errMsg("id required") + } + if in.Name == nil && in.Location == nil && in.Width == nil && in.WIPLimit == nil && in.IsDone == nil { + return errMsg("at least one of name/location/width/wip_limit/is_done required") + } + if err := db.UpdateColumn(in.ID, ColumnPatch{Name: in.Name, Location: in.Location, Width: in.Width, WIPLimit: in.WIPLimit, IsDone: in.IsDone}); err != nil { + return errResult(err) + } + return okResult(nil) +} + +func toolDeleteColumn(db *DB, input json.RawMessage) ToolResult { + var in struct{ ID string } + if err := json.Unmarshal(input, &in); err != nil { + return errResult(err) + } + if in.ID == "" { + return errMsg("id required") + } + if err := db.DeleteColumn(in.ID); err != nil { + return errResult(err) + } + return okResult(nil) +} + +func toolReorderColumns(db *DB, input json.RawMessage) ToolResult { + var in struct{ IDs []string } + if err := json.Unmarshal(input, &in); err != nil { + return errResult(err) + } + if len(in.IDs) == 0 { + return errMsg("ids required") + } + if err := db.ReorderColumns(in.IDs); err != nil { + return errResult(err) + } + return okResult(nil) +} + +func toolCreateCard(db *DB, input json.RawMessage) ToolResult { + var in struct { + ColumnID string `json:"column_id"` + Requester string `json:"requester"` + Title string `json:"title"` + Description string `json:"description"` + } + if err := json.Unmarshal(input, &in); err != nil { + return errResult(err) + } + if in.ColumnID == "" || strings.TrimSpace(in.Title) == "" { + return errMsg("column_id and title required") + } + c, err := db.CreateCard(in.ColumnID, in.Requester, in.Title, in.Description, "") + if err != nil { + return errResult(err) + } + return okResult(c) +} + +func toolUpdateCard(db *DB, input json.RawMessage) ToolResult { + var raw map[string]any + if err := json.Unmarshal(input, &raw); err != nil { + return errResult(err) + } + id, _ := raw["id"].(string) + if id == "" { + return errMsg("id required") + } + patch := CardPatch{} + if v, ok := raw["requester"].(string); ok { + patch.Requester = &v + } + if v, ok := raw["title"].(string); ok { + patch.Title = &v + } + if v, ok := raw["description"].(string); ok { + patch.Description = &v + } + if v, ok := raw["color"].(string); ok { + patch.Color = &v + } + if v, ok := raw["locked"].(bool); ok { + patch.Locked = &v + } + if v, present := raw["assignee_id"]; present { + patch.HasAssignee = true + if v == nil { + empty := "" + patch.AssigneeID = &empty + } else if s, ok := v.(string); ok { + patch.AssigneeID = &s + } + } + if err := db.UpdateCard(id, patch); err != nil { + return errResult(err) + } + return okResult(nil) +} + +func toolListUsers(db *DB) ToolResult { + users, err := db.ListUsers() + if err != nil { + return errResult(err) + } + return okResult(users) +} + +func toolAssignCard(db *DB, input json.RawMessage) ToolResult { + var in struct { + ID string `json:"id"` + AssigneeID *string `json:"assignee_id"` + } + if err := json.Unmarshal(input, &in); err != nil { + return errResult(err) + } + if in.ID == "" { + return errMsg("id required") + } + patch := CardPatch{HasAssignee: true} + if in.AssigneeID == nil { + empty := "" + patch.AssigneeID = &empty + } else { + patch.AssigneeID = in.AssigneeID + } + if err := db.UpdateCard(in.ID, patch); err != nil { + return errResult(err) + } + return okResult(nil) +} + +func toolDeleteCard(db *DB, input json.RawMessage) ToolResult { + var in struct{ ID string } + if err := json.Unmarshal(input, &in); err != nil { + return errResult(err) + } + if in.ID == "" { + return errMsg("id required") + } + if err := db.DeleteCard(in.ID); err != nil { + return errResult(err) + } + return okResult(nil) +} + +// toolMoveCard accepts {id, column_id, ordered_ids?}. If ordered_ids is missing, +// the card is appended to the end of the destination column. +func toolMoveCard(db *DB, input json.RawMessage) ToolResult { + var in struct { + ID string `json:"id"` + ColumnID string `json:"column_id"` + OrderedIDs []string `json:"ordered_ids"` + } + if err := json.Unmarshal(input, &in); err != nil { + return errResult(err) + } + if in.ID == "" || in.ColumnID == "" { + return errMsg("id and column_id required") + } + if len(in.OrderedIDs) == 0 { + cards, err := db.ListCardsWithTime() + if err != nil { + return errResult(err) + } + var dest []Card + for _, c := range cards { + if c.ColumnID == in.ColumnID && c.ID != in.ID { + dest = append(dest, c) + } + } + sort.Slice(dest, func(i, j int) bool { return dest[i].Position < dest[j].Position }) + ids := make([]string, 0, len(dest)+1) + for _, c := range dest { + ids = append(ids, c.ID) + } + ids = append(ids, in.ID) + in.OrderedIDs = ids + } + if err := db.MoveCard(in.ID, in.ColumnID, in.OrderedIDs, ""); err != nil { + return errResult(err) + } + return okResult(nil) +} + +func toolCardHistory(db *DB, input json.RawMessage) ToolResult { + var in struct{ ID string } + if err := json.Unmarshal(input, &in); err != nil { + return errResult(err) + } + if in.ID == "" { + return errMsg("id required") + } + hist, err := db.CardHistory(in.ID) + if err != nil { + return errResult(err) + } + return okResult(hist) +} + +func toolFindCards(db *DB, input json.RawMessage) ToolResult { + var in struct { + Query string `json:"query"` + ColumnID string `json:"column_id"` + Requester string `json:"requester"` + } + if err := json.Unmarshal(input, &in); err != nil { + return errResult(err) + } + cards, err := db.ListCardsWithTime() + if err != nil { + return errResult(err) + } + q := strings.ToLower(strings.TrimSpace(in.Query)) + col := in.ColumnID + req := strings.ToLower(strings.TrimSpace(in.Requester)) + out := make([]Card, 0, len(cards)) + for _, c := range cards { + if col != "" && c.ColumnID != col { + continue + } + if req != "" && !strings.Contains(strings.ToLower(c.Requester), req) { + continue + } + if q != "" { + hay := strings.ToLower(c.Title + " " + c.Description + " " + c.Requester) + if !strings.Contains(hay, q) { + continue + } + } + out = append(out, c) + } + return okResult(out) +} + +// validateToolName fails fast with clearer error than the dispatch's default. +func validateToolName(name string) error { + known := map[string]bool{ + "list_board": true, "create_column": true, "update_column": true, "rename_column": true, + "delete_column": true, "reorder_columns": true, "create_card": true, + "update_card": true, "delete_card": true, "move_card": true, + "card_history": true, "find_cards": true, + "list_users": true, "assign_card": true, + } + if !known[name] { + return fmt.Errorf("unknown tool: %s", name) + } + return nil +} diff --git a/backend/tools_test.go b/backend/tools_test.go new file mode 100644 index 0000000..8d50f31 --- /dev/null +++ b/backend/tools_test.go @@ -0,0 +1,399 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// setupTestDB creates a temporary kanban DB for the duration of the test. +func setupTestDB(t *testing.T) *DB { + t.Helper() + dir := t.TempDir() + dbPath := filepath.Join(dir, "test_operations.db") + db, err := openDB(dbPath) + if err != nil { + t.Fatalf("openDB: %v", err) + } + t.Cleanup(func() { db.Close() }) + return db +} + +func mustJSON(t *testing.T, v any) json.RawMessage { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return b +} + +func mustOK(t *testing.T, res ToolResult) { + t.Helper() + if !res.OK { + t.Fatalf("expected ok, got error: %s", res.Error) + } +} + +func mustErr(t *testing.T, res ToolResult, contains string) { + t.Helper() + if res.OK { + t.Fatalf("expected error, got ok with result: %v", res.Result) + } + if contains != "" && !strings.Contains(res.Error, contains) { + t.Fatalf("error %q does not contain %q", res.Error, contains) + } +} + +// --- list_board --- + +func TestExecuteTool_ListBoard_Empty(t *testing.T) { + db := setupTestDB(t) + res := executeTool(db, "list_board", json.RawMessage(`{}`)) + mustOK(t, res) + board, ok := res.Result.(map[string]any) + if !ok { + t.Fatalf("expected map[string]any, got %T", res.Result) + } + cols := board["columns"].([]Column) + cards := board["cards"].([]Card) + if len(cols) != 0 || len(cards) != 0 { + t.Fatalf("expected empty board, got %d cols %d cards", len(cols), len(cards)) + } +} + +// --- create_column / rename_column / delete_column / reorder_columns --- + +func TestExecuteTool_CreateColumn(t *testing.T) { + db := setupTestDB(t) + res := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "Backlog"})) + mustOK(t, res) + col := res.Result.(*Column) + if col.Name != "Backlog" || col.Position != 0 || col.ID == "" { + t.Fatalf("unexpected column: %+v", col) + } +} + +func TestExecuteTool_CreateColumn_EmptyName(t *testing.T) { + db := setupTestDB(t) + res := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": " "})) + mustErr(t, res, "name required") +} + +func TestExecuteTool_UpdateColumn_Name(t *testing.T) { + db := setupTestDB(t) + created := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "Old"})) + col := created.Result.(*Column) + + res := executeTool(db, "update_column", mustJSON(t, map[string]string{"id": col.ID, "name": "New"})) + mustOK(t, res) + + cols, _ := db.ListColumns() + if cols[0].Name != "New" { + t.Fatalf("rename failed: %s", cols[0].Name) + } +} + +func TestExecuteTool_UpdateColumn_LocationAndWidth(t *testing.T) { + db := setupTestDB(t) + col := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "X"})).Result.(*Column) + + loc := "sidebar" + width := 450 + res := executeTool(db, "update_column", mustJSON(t, map[string]any{"id": col.ID, "location": loc, "width": width})) + mustOK(t, res) + + cols, _ := db.ListColumns() + if cols[0].Location != "sidebar" || cols[0].Width != 450 { + t.Fatalf("update failed: %+v", cols[0]) + } +} + +func TestExecuteTool_UpdateColumn_NoFields(t *testing.T) { + db := setupTestDB(t) + col := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "X"})).Result.(*Column) + res := executeTool(db, "update_column", mustJSON(t, map[string]string{"id": col.ID})) + mustErr(t, res, "at least one") +} + +func TestExecuteTool_RenameColumn_AliasStillWorks(t *testing.T) { + db := setupTestDB(t) + col := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "Old"})).Result.(*Column) + res := executeTool(db, "rename_column", mustJSON(t, map[string]string{"id": col.ID, "name": "New"})) + mustOK(t, res) + cols, _ := db.ListColumns() + if cols[0].Name != "New" { + t.Fatalf("alias rename failed") + } +} + +func TestExecuteTool_DeleteColumn(t *testing.T) { + db := setupTestDB(t) + created := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "Tmp"})) + col := created.Result.(*Column) + + res := executeTool(db, "delete_column", mustJSON(t, map[string]string{"id": col.ID})) + mustOK(t, res) + + cols, _ := db.ListColumns() + if len(cols) != 0 { + t.Fatalf("expected 0 cols after delete, got %d", len(cols)) + } +} + +func TestExecuteTool_ReorderColumns(t *testing.T) { + db := setupTestDB(t) + a := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "A"})).Result.(*Column) + b := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "B"})).Result.(*Column) + c := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "C"})).Result.(*Column) + + res := executeTool(db, "reorder_columns", mustJSON(t, map[string][]string{"ids": {c.ID, a.ID, b.ID}})) + mustOK(t, res) + + cols, _ := db.ListColumns() + got := []string{cols[0].Name, cols[1].Name, cols[2].Name} + want := []string{"C", "A", "B"} + for i := range want { + if got[i] != want[i] { + t.Fatalf("reorder mismatch at %d: want %s got %s", i, want[i], got[i]) + } + } +} + +// --- create_card / update_card / delete_card / move_card --- + +func TestExecuteTool_CreateCard_AndRequester(t *testing.T) { + db := setupTestDB(t) + col := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "Todo"})).Result.(*Column) + + res := executeTool(db, "create_card", mustJSON(t, map[string]string{ + "column_id": col.ID, + "requester": "Lucas", + "title": "Buy milk", + "description": "Whole milk", + })) + mustOK(t, res) + card := res.Result.(*Card) + if card.Requester != "Lucas" || card.Title != "Buy milk" || card.ColumnID != col.ID { + t.Fatalf("unexpected card: %+v", card) + } +} + +func TestExecuteTool_CreateCard_MissingTitle(t *testing.T) { + db := setupTestDB(t) + col := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "X"})).Result.(*Column) + res := executeTool(db, "create_card", mustJSON(t, map[string]string{ + "column_id": col.ID, + "title": "", + })) + mustErr(t, res, "required") +} + +func TestExecuteTool_UpdateCard(t *testing.T) { + db := setupTestDB(t) + col := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "X"})).Result.(*Column) + card := executeTool(db, "create_card", mustJSON(t, map[string]string{ + "column_id": col.ID, + "requester": "A", + "title": "T1", + })).Result.(*Card) + + newTitle := "T2" + newReq := "B" + color := "violet" + res := executeTool(db, "update_card", mustJSON(t, map[string]any{ + "id": card.ID, + "title": newTitle, + "requester": newReq, + "color": color, + })) + mustOK(t, res) + + cards, _ := db.ListCardsWithTime() + if cards[0].Title != "T2" || cards[0].Requester != "B" || cards[0].Color != "violet" { + t.Fatalf("unexpected card after update: %+v", cards[0]) + } +} + +func TestExecuteTool_DeleteCard(t *testing.T) { + db := setupTestDB(t) + col := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "X"})).Result.(*Column) + card := executeTool(db, "create_card", mustJSON(t, map[string]string{ + "column_id": col.ID, + "title": "T", + })).Result.(*Card) + + res := executeTool(db, "delete_card", mustJSON(t, map[string]string{"id": card.ID})) + mustOK(t, res) + + cards, _ := db.ListCardsWithTime() + if len(cards) != 0 { + t.Fatalf("expected 0 cards, got %d", len(cards)) + } +} + +func TestExecuteTool_MoveCard_BetweenColumns_OpensHistory(t *testing.T) { + db := setupTestDB(t) + src := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "Src"})).Result.(*Column) + dst := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "Dst"})).Result.(*Column) + card := executeTool(db, "create_card", mustJSON(t, map[string]string{ + "column_id": src.ID, + "title": "Move me", + })).Result.(*Card) + + res := executeTool(db, "move_card", mustJSON(t, map[string]any{ + "id": card.ID, + "column_id": dst.ID, + })) + mustOK(t, res) + + cards, _ := db.ListCardsWithTime() + if cards[0].ColumnID != dst.ID { + t.Fatalf("card not moved, still in %s", cards[0].ColumnID) + } + + histRes := executeTool(db, "card_history", mustJSON(t, map[string]string{"id": card.ID})) + mustOK(t, histRes) + hist := histRes.Result.([]HistoryEntry) + if len(hist) != 2 { + t.Fatalf("expected 2 history entries, got %d", len(hist)) + } + if hist[0].ExitedAt == nil { + t.Fatalf("first entry should be closed") + } + if hist[1].ExitedAt != nil { + t.Fatalf("second entry should be open") + } +} + +func TestExecuteTool_MoveCard_RequiresIDAndColumn(t *testing.T) { + db := setupTestDB(t) + res := executeTool(db, "move_card", mustJSON(t, map[string]string{"id": ""})) + mustErr(t, res, "required") +} + +// --- card_history --- + +func TestExecuteTool_CardHistory_Single(t *testing.T) { + db := setupTestDB(t) + col := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "X"})).Result.(*Column) + card := executeTool(db, "create_card", mustJSON(t, map[string]string{ + "column_id": col.ID, + "title": "T", + })).Result.(*Card) + + res := executeTool(db, "card_history", mustJSON(t, map[string]string{"id": card.ID})) + mustOK(t, res) + hist := res.Result.([]HistoryEntry) + if len(hist) != 1 || hist[0].ExitedAt != nil { + t.Fatalf("expected 1 open history entry, got %+v", hist) + } +} + +// --- find_cards --- + +func TestExecuteTool_FindCards_FilterByQueryRequesterColumn(t *testing.T) { + db := setupTestDB(t) + col := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "X"})).Result.(*Column) + col2 := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "Y"})).Result.(*Column) + executeTool(db, "create_card", mustJSON(t, map[string]string{"column_id": col.ID, "requester": "Lucas", "title": "Bug fix"})) + executeTool(db, "create_card", mustJSON(t, map[string]string{"column_id": col.ID, "requester": "Ana", "title": "Feature x"})) + executeTool(db, "create_card", mustJSON(t, map[string]string{"column_id": col2.ID, "requester": "Lucas", "title": "Refactor"})) + + // query + r := executeTool(db, "find_cards", mustJSON(t, map[string]string{"query": "fix"})) + mustOK(t, r) + cards := r.Result.([]Card) + if len(cards) != 1 || cards[0].Title != "Bug fix" { + t.Fatalf("query filter failed: %+v", cards) + } + + // requester + r = executeTool(db, "find_cards", mustJSON(t, map[string]string{"requester": "Lucas"})) + cards = r.Result.([]Card) + if len(cards) != 2 { + t.Fatalf("requester filter expected 2 got %d", len(cards)) + } + + // column + r = executeTool(db, "find_cards", mustJSON(t, map[string]string{"column_id": col2.ID})) + cards = r.Result.([]Card) + if len(cards) != 1 || cards[0].ColumnID != col2.ID { + t.Fatalf("column filter failed: %+v", cards) + } + + // combined + r = executeTool(db, "find_cards", mustJSON(t, map[string]any{"requester": "Lucas", "column_id": col.ID})) + cards = r.Result.([]Card) + if len(cards) != 1 || cards[0].Title != "Bug fix" { + t.Fatalf("combined filter failed: %+v", cards) + } +} + +// --- unknown tool --- + +func TestExecuteTool_Unknown(t *testing.T) { + db := setupTestDB(t) + res := executeTool(db, "no_such_tool", json.RawMessage(`{}`)) + mustErr(t, res, "unknown tool") +} + +// --- chat logger --- + +func TestChatLogger_AppendsJSONLines(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "chat.log") + logger := newChatLogger(path) + + logger.Log("create_column", json.RawMessage(`{"name":"A"}`), ToolResult{OK: true, Result: &Column{ID: "abc", Name: "A"}}) + logger.Log("delete_card", json.RawMessage(`{"id":"x"}`), ToolResult{OK: false, Error: "card not found"}) + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read log: %v", err) + } + lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n") + if len(lines) != 2 { + t.Fatalf("expected 2 log lines, got %d", len(lines)) + } + for i, line := range lines { + var entry ChatLogEntry + if err := json.Unmarshal([]byte(line), &entry); err != nil { + t.Fatalf("line %d not valid JSON: %v\n%s", i, err, line) + } + if entry.TS == "" { + t.Fatalf("line %d missing TS", i) + } + } + + var first, second ChatLogEntry + json.Unmarshal([]byte(lines[0]), &first) + json.Unmarshal([]byte(lines[1]), &second) + if first.Tool != "create_column" || !first.OK { + t.Fatalf("unexpected first entry: %+v", first) + } + if second.Tool != "delete_card" || second.OK || second.Error != "card not found" { + t.Fatalf("unexpected second entry: %+v", second) + } +} + +// --- toolMutates --- + +func TestToolMutates(t *testing.T) { + mutating := []string{"create_column", "update_column", "rename_column", "delete_column", "reorder_columns", + "create_card", "update_card", "delete_card", "move_card"} + readonly := []string{"list_board", "card_history", "find_cards"} + + for _, n := range mutating { + if !toolMutates(n) { + t.Errorf("expected %s to mutate", n) + } + } + for _, n := range readonly { + if toolMutates(n) { + t.Errorf("expected %s to be read-only", n) + } + } +} diff --git a/backend/users.go b/backend/users.go new file mode 100644 index 0000000..b6b49ce --- /dev/null +++ b/backend/users.go @@ -0,0 +1,129 @@ +package main + +import ( + "database/sql" + "errors" + "fmt" + "strings" + + "fn-registry/functions/infra" +) + +type User struct { + ID string `json:"id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + Color string `json:"color"` + CreatedAt string `json:"created_at"` +} + +var ( + errUserNotFound = errors.New("user not found") + errUserAlreadyExists = errors.New("username already exists") + errInvalidCredentials = errors.New("invalid credentials") +) + +func (db *DB) CreateUser(username, password, displayName string) (*User, error) { + username = strings.TrimSpace(strings.ToLower(username)) + if username == "" { + return nil, fmt.Errorf("username required") + } + if len(password) < 4 { + return nil, fmt.Errorf("password must be at least 4 characters") + } + hash, err := infra.PasswordHash(password, 0) + if err != nil { + return nil, fmt.Errorf("hash: %w", err) + } + u := User{ID: newID(), Username: username, DisplayName: displayName, CreatedAt: nowRFC3339()} + _, err = db.conn.Exec( + `INSERT INTO users (id, username, password_hash, display_name, created_at) VALUES (?, ?, ?, ?, ?)`, + u.ID, u.Username, hash, u.DisplayName, u.CreatedAt, + ) + if err != nil { + if strings.Contains(err.Error(), "UNIQUE") { + return nil, errUserAlreadyExists + } + return nil, err + } + return &u, nil +} + +func (db *DB) GetUserByID(id string) (*User, error) { + var u User + err := db.conn.QueryRow( + `SELECT id, username, display_name, color, created_at FROM users WHERE id=?`, id, + ).Scan(&u.ID, &u.Username, &u.DisplayName, &u.Color, &u.CreatedAt) + if errors.Is(err, sql.ErrNoRows) { + return nil, errUserNotFound + } + if err != nil { + return nil, err + } + return &u, nil +} + +func (db *DB) GetUserByUsername(username string) (*User, string, error) { + username = strings.TrimSpace(strings.ToLower(username)) + var u User + var hash string + err := db.conn.QueryRow( + `SELECT id, username, display_name, color, created_at, password_hash FROM users WHERE username=?`, username, + ).Scan(&u.ID, &u.Username, &u.DisplayName, &u.Color, &u.CreatedAt, &hash) + if errors.Is(err, sql.ErrNoRows) { + return nil, "", errUserNotFound + } + if err != nil { + return nil, "", err + } + return &u, hash, nil +} + +func (db *DB) ListUsers() ([]User, error) { + rows, err := db.conn.Query(`SELECT id, username, display_name, color, created_at FROM users ORDER BY username`) + if err != nil { + return nil, err + } + defer rows.Close() + out := []User{} + for rows.Next() { + var u User + if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Color, &u.CreatedAt); err != nil { + return nil, err + } + out = append(out, u) + } + return out, rows.Err() +} + +func (db *DB) Authenticate(username, password string) (*User, error) { + u, hash, err := db.GetUserByUsername(username) + if err != nil { + if errors.Is(err, errUserNotFound) { + return nil, errInvalidCredentials + } + return nil, err + } + if err := infra.PasswordVerify(password, hash); err != nil { + return nil, errInvalidCredentials + } + return u, nil +} + +func (db *DB) CountUsers() (int, error) { + var n int + if err := db.conn.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n); err != nil { + return 0, err + } + return n, nil +} + +func (db *DB) UpdateUserColor(id, color string) error { + _, err := db.conn.Exec(`UPDATE users SET color=? WHERE id=?`, color, id) + return err +} + +func (db *DB) DeleteSessionByToken(token string) error { + _, err := db.conn.Exec(`DELETE FROM sessions WHERE token=?`, token) + return err +} diff --git a/data.cpp b/data.cpp new file mode 100644 index 0000000..32edd1a --- /dev/null +++ b/data.cpp @@ -0,0 +1,174 @@ +// data.cpp — HTTP client implementation for kanban_cpp. +// +// JSON parsing is intentionally manual + permissive: backend is "ours" and +// payload shapes are stable. If we ever need a real parser, swap to nlohmann +// or rapidjson; today the extra dep is not justified (KISS). +#include "data.h" + +#include "core/http_request.h" +#include "core/logger.h" + +#include +#include + +namespace kanban_cpp { + +namespace { + +// Tiny helpers: scan JSON strings out of a raw buffer. NOT a real parser — +// only handles flat-ish payloads our backend emits. Good enough for MVP. +std::string find_str_field(const std::string& s, const std::string& key) { + std::string needle = "\"" + key + "\":"; + size_t p = s.find(needle); + if (p == std::string::npos) return ""; + p += needle.size(); + while (p < s.size() && (s[p] == ' ' || s[p] == '\t')) ++p; + if (p >= s.size() || s[p] != '"') return ""; + ++p; + std::string out; + while (p < s.size() && s[p] != '"') { + if (s[p] == '\\' && p + 1 < s.size()) { + char c = s[p + 1]; + if (c == 'n') out += '\n'; + else if (c == 't') out += '\t'; + else out += c; + p += 2; + continue; + } + out += s[p++]; + } + return out; +} + +int64_t find_int_field(const std::string& s, const std::string& key) { + std::string needle = "\"" + key + "\":"; + size_t p = s.find(needle); + if (p == std::string::npos) return 0; + p += needle.size(); + while (p < s.size() && (s[p] == ' ' || s[p] == '\t')) ++p; + char* end = nullptr; + long long v = std::strtoll(s.c_str() + p, &end, 10); + return static_cast(v); +} + +// Split JSON array of objects at depth 1. Returns each object as a substring. +std::vector split_objects(const std::string& s) { + std::vector out; + int depth = 0; + size_t start = 0; + bool in_obj = false; + for (size_t i = 0; i < s.size(); ++i) { + char c = s[i]; + if (c == '{') { + if (depth == 0) { start = i; in_obj = true; } + ++depth; + } else if (c == '}') { + --depth; + if (depth == 0 && in_obj) { + out.push_back(s.substr(start, i - start + 1)); + in_obj = false; + } + } + } + return out; +} + +fn_http::Response do_get(const std::string& url, int timeout_ms) { + fn_http::Request req; + req.method = "GET"; + req.url = url; + req.timeout_ms = timeout_ms; + return fn_http::request(req); +} + +fn_http::Response do_post_json(const std::string& url, const std::string& body, int timeout_ms) { + fn_http::Request req; + req.method = "POST"; + req.url = url; + req.timeout_ms = timeout_ms; + req.body = body; + req.headers.push_back({"Content-Type", "application/json"}); + return fn_http::request(req); +} + +} // namespace + +bool health(const ClientConfig& cfg) { + auto r = do_get(cfg.base_url + "/health", cfg.timeout_ms); + return r.status >= 200 && r.status < 300; +} + +std::vector list_cards(const ClientConfig& cfg, std::string& err) { + auto r = do_get(cfg.base_url + "/api/cards", cfg.timeout_ms); + if (r.status == 0) { err = "transport: " + r.error; return {}; } + if (r.status >= 400) { err = "http " + std::to_string(r.status); return {}; } + std::vector out; + for (const auto& obj : split_objects(r.body)) { + Card c; + c.id = find_str_field(obj, "id"); + c.title = find_str_field(obj, "title"); + c.description = find_str_field(obj, "description"); + c.column_id = find_str_field(obj, "column_id"); + c.priority = find_str_field(obj, "priority"); + c.status = find_str_field(obj, "status"); + c.position = find_int_field(obj, "position"); + c.due_date = find_int_field(obj, "due_date"); + c.assignee = find_str_field(obj, "assignee"); + if (!c.id.empty()) out.push_back(c); + } + return out; +} + +std::vector list_columns(const ClientConfig& cfg, std::string& err) { + auto r = do_get(cfg.base_url + "/api/columns", cfg.timeout_ms); + if (r.status == 0) { err = "transport: " + r.error; return {}; } + if (r.status >= 400) { err = "http " + std::to_string(r.status); return {}; } + std::vector out; + for (const auto& obj : split_objects(r.body)) { + Column c; + c.id = find_str_field(obj, "id"); + c.name = find_str_field(obj, "name"); + c.order = static_cast(find_int_field(obj, "order")); + if (!c.id.empty()) out.push_back(c); + } + return out; +} + +bool move_card(const ClientConfig& cfg, const std::string& card_id, + const std::string& new_column_id, std::string& err) { + std::string body = "{\"column_id\":\"" + new_column_id + "\"}"; + auto r = do_post_json(cfg.base_url + "/api/cards/" + card_id + "/move", body, cfg.timeout_ms); + if (r.status == 0) { err = "transport: " + r.error; return false; } + if (r.status >= 400) { err = "http " + std::to_string(r.status); return false; } + return true; +} + +std::vector list_runs(const ClientConfig& cfg, std::string& err) { + auto r = do_get(cfg.agent_runner_url + "/api/runs", cfg.timeout_ms); + if (r.status == 0) { err = "transport: " + r.error; return {}; } + if (r.status >= 400) { err = "http " + std::to_string(r.status); return {}; } + std::vector out; + for (const auto& obj : split_objects(r.body)) { + AgentRunSummary s; + s.id = find_str_field(obj, "id"); + s.card_id = find_str_field(obj, "card_id"); + s.branch = find_str_field(obj, "branch"); + s.status = find_str_field(obj, "status"); + s.started_at = find_int_field(obj, "started_at"); + s.finished_at = find_int_field(obj, "finished_at"); + if (!s.id.empty()) out.push_back(s); + } + return out; +} + +bool launch_workflow(const ClientConfig& cfg, const std::string& card_id, + std::string& out_run_id, std::string& err) { + std::string body = "{\"card_id\":\"" + card_id + "\"}"; + auto r = do_post_json(cfg.agent_runner_url + "/api/runs", body, cfg.timeout_ms); + if (r.status == 0) { err = "transport: " + r.error; return false; } + if (r.status >= 400) { err = "http " + std::to_string(r.status); return false; } + out_run_id = find_str_field(r.body, "id"); + return true; +} + +} // namespace kanban_cpp diff --git a/data.h b/data.h new file mode 100644 index 0000000..f543fc2 --- /dev/null +++ b/data.h @@ -0,0 +1,61 @@ +// data.h — HTTP client wrapper for kanban_cpp backend at :8403. +// +// Wraps fn_http::request() (cpp/functions/core/http_request.h) with +// kanban-specific shapes (Card, Column, AgentRunSummary). +#pragma once + +#include +#include +#include + +namespace kanban_cpp { + +struct Card { + std::string id; + std::string title; + std::string description; + std::string column_id; + std::string priority; // low|medium|high|critical + std::string status; // pending|doing|done|... + int64_t position = 0; + int64_t due_date = 0; // unix seconds, 0 = no due + std::string assignee; + std::vector labels; +}; + +struct Column { + std::string id; + std::string name; + int order = 0; +}; + +struct AgentRunSummary { + std::string id; + std::string card_id; + std::string branch; + std::string status; + int64_t started_at = 0; + int64_t finished_at = 0; +}; + +struct ClientConfig { + std::string base_url = "http://127.0.0.1:8403"; + std::string agent_runner_url = "http://127.0.0.1:8486"; + int timeout_ms = 3000; +}; + +// HTTP GETs --------------------------------------------------------------- +std::vector list_cards(const ClientConfig& cfg, std::string& err); +std::vector list_columns(const ClientConfig& cfg, std::string& err); +bool health(const ClientConfig& cfg); // GET /health + +// HTTP mutations ---------------------------------------------------------- +bool move_card(const ClientConfig& cfg, const std::string& card_id, + const std::string& new_column_id, std::string& err); + +// agent_runner_api ------------------------------------------------------- +std::vector list_runs(const ClientConfig& cfg, std::string& err); +bool launch_workflow(const ClientConfig& cfg, const std::string& card_id, + std::string& out_run_id, std::string& err); + +} // namespace kanban_cpp diff --git a/main.cpp b/main.cpp new file mode 100644 index 0000000..40888b2 --- /dev/null +++ b/main.cpp @@ -0,0 +1,73 @@ +// main.cpp — kanban_cpp entry point. +// +// Six panels declared via cfg.panels. fn::run_app paints the menubar / +// dockspace / about / layouts automatically. +#include "app_base.h" +#include "core/panel_menu.h" +#include "core/icons_tabler.h" +#include "core/logger.h" +#include "panels.h" + +#include +#include +#include +#include + +static bool g_show_board = true; +static bool g_show_calendar = true; +static bool g_show_dashboard = true; +static bool g_show_runs = true; +static bool g_show_worktrees = true; +static bool g_show_dod = true; + +static kanban_cpp::AppState g_state; + +static void render() { + if (g_show_board) kanban_cpp::draw_board (g_state, &g_show_board); + if (g_show_calendar) kanban_cpp::draw_calendar (g_state, &g_show_calendar); + if (g_show_dashboard) kanban_cpp::draw_dashboard (g_state, &g_show_dashboard); + if (g_show_runs) kanban_cpp::draw_agent_runs(g_state, &g_show_runs); + if (g_show_worktrees) kanban_cpp::draw_worktrees (g_state, &g_show_worktrees); + if (g_show_dod) kanban_cpp::draw_dod (g_state, &g_show_dod); +} + +// Headless self-test: verifies the binary links, panels include compile, +// and the data layer accepts a config. Used by app.md e2e_checks. +static int run_self_test() { + std::printf("kanban_cpp --self-test\n"); + kanban_cpp::AppState s; + s.cfg.base_url = "http://127.0.0.1:65535"; // unreachable on purpose + bool ok = kanban_cpp::health(s.cfg); + std::printf(" health(unreachable) = %s (expected: false)\n", ok ? "true" : "false"); + if (ok) return 1; + std::printf("OK\n"); + return 0; +} + +int main(int argc, char** argv) { + for (int i = 1; i < argc; ++i) { + if (std::strcmp(argv[i], "--self-test") == 0) return run_self_test(); + } + + static fn_ui::PanelToggle panels[] = { + { "Board", nullptr, &g_show_board }, + { "Calendar", nullptr, &g_show_calendar }, + { "Dashboard", nullptr, &g_show_dashboard }, + { "Agent runs", nullptr, &g_show_runs }, + { "Worktrees", nullptr, &g_show_worktrees }, + { "DoD inspector", nullptr, &g_show_dod }, + }; + + fn::AppConfig cfg; + cfg.title = "kanban_cpp — agentes LLM con DoD"; + cfg.about = { "kanban_cpp", "0.1.0", + "Clon C++ ImGui de kanban_web — agentes LLM con DoD evidence" }; + cfg.log = { "kanban_cpp.log", 1 }; + cfg.panels = panels; + cfg.panel_count = sizeof(panels) / sizeof(panels[0]); + + // First refresh on startup (best-effort; failure surfaces in the Board). + kanban_cpp::refresh_data(g_state); + + return fn::run_app(cfg, render); +} diff --git a/panel_agent_runs.cpp b/panel_agent_runs.cpp new file mode 100644 index 0000000..d0d4dc8 --- /dev/null +++ b/panel_agent_runs.cpp @@ -0,0 +1,72 @@ +// panel_agent_runs.cpp — wraps the registry fn_viz::render_agent_runs_timeline. +// +// HTTP polling against agent_runner_api:8486. If the API is offline the +// panel shows `disconnected` and the table stays empty — never blocks the +// UI thread (calls happen lazily via Refresh button). +#include "panels.h" +#include "core/icons_tabler.h" +#include "viz/agent_runs_timeline.h" + +#include +#include + +namespace kanban_cpp { + +namespace { + +fn_viz::TimelineState& state_singleton() { + static fn_viz::TimelineState s; + static bool inited = false; + if (!inited) { + s.sse_url = "http://127.0.0.1:8486/api/runs/stream"; + s.connection_status = "disconnected"; + inited = true; + } + return s; +} + +void poll_runs(AppState& app) { + auto& ts = state_singleton(); + std::string err; + auto runs = list_runs(app.cfg, err); + if (!err.empty()) { + std::lock_guard lk(ts.runs_mutex); + ts.connection_status = "disconnected"; + return; + } + std::lock_guard lk(ts.runs_mutex); + ts.runs.clear(); + for (const auto& r : runs) { + fn_viz::AgentRun ar; + ar.id = r.id; + ar.app = "kanban_cpp"; + ar.card_id = r.card_id; + ar.branch = r.branch; + ar.status = r.status; + ar.started_at = r.started_at; + ar.finished_at = r.finished_at; + ts.runs.push_back(ar); + } + ts.connection_status = "connected"; +} + +} // namespace + +void draw_agent_runs(AppState& app, bool* p_open) { + if (!ImGui::Begin(TI_ROBOT " Agent runs", p_open)) { + ImGui::End(); + return; + } + + auto& ts = state_singleton(); + if (ImGui::Button(TI_REFRESH " Poll agent_runner_api")) poll_runs(app); + ImGui::SameLine(); + ImGui::TextDisabled("%s", ts.connection_status.c_str()); + + ImGui::Separator(); + fn_viz::render_agent_runs_timeline(ts); + + ImGui::End(); +} + +} // namespace kanban_cpp diff --git a/panel_board.cpp b/panel_board.cpp new file mode 100644 index 0000000..74bf395 --- /dev/null +++ b/panel_board.cpp @@ -0,0 +1,113 @@ +// panel_board.cpp — columns + cards Kanban panel. +#include "panels.h" +#include "core/icons_tabler.h" + +#include +#include + +namespace kanban_cpp { + +void refresh_data(AppState& s) { + std::string err; + s.cards = list_cards(s.cfg, err); + if (!err.empty()) s.last_refresh_error = "cards: " + err; + s.columns = list_columns(s.cfg, err); + if (!err.empty()) s.last_refresh_error += " columns: " + err; + s.backend_ok = health(s.cfg); + s.last_refresh_ts = std::time(nullptr); +} + +void draw_board(AppState& s, bool* p_open) { + if (!ImGui::Begin(TI_LAYOUT_KANBAN " Board", p_open)) { + ImGui::End(); + return; + } + + // Toolbar + if (ImGui::Button(TI_REFRESH " Refresh")) refresh_data(s); + ImGui::SameLine(); + if (s.backend_ok) { + ImGui::TextColored(ImVec4(0.4f, 0.85f, 0.4f, 1.0f), TI_CHECK " backend :8403"); + } else { + ImGui::TextColored(ImVec4(0.85f, 0.4f, 0.4f, 1.0f), TI_ALERT_TRIANGLE " backend offline (:8403)"); + } + if (!s.last_refresh_error.empty()) { + ImGui::SameLine(); + ImGui::TextColored(ImVec4(0.85f, 0.6f, 0.2f, 1.0f), "%s", s.last_refresh_error.c_str()); + } + ImGui::Separator(); + + // Empty state + if (s.columns.empty()) { + ImGui::TextDisabled("No columns yet. Pulsa Refresh o lanza el backend en :8403."); + ImGui::End(); + return; + } + + // Render columns left-to-right + const float col_w = 280.0f; + if (ImGui::BeginChild("##board_scroll", ImVec2(0, 0), false, + ImGuiWindowFlags_HorizontalScrollbar)) { + for (size_t ci = 0; ci < s.columns.size(); ++ci) { + const auto& col = s.columns[ci]; + ImGui::SameLine(); + ImGui::BeginChild((std::string("##col_") + col.id).c_str(), + ImVec2(col_w, 0), true); + ImGui::TextUnformatted(col.name.c_str()); + ImGui::SameLine(); + int count = 0; + for (const auto& c : s.cards) if (c.column_id == col.id) ++count; + ImGui::TextDisabled("(%d)", count); + ImGui::Separator(); + + for (const auto& card : s.cards) { + if (card.column_id != col.id) continue; + ImGui::PushID(card.id.c_str()); + ImGui::BeginChild("##card", ImVec2(0, 70), true, + ImGuiWindowFlags_NoScrollbar); + ImGui::TextUnformatted(card.title.c_str()); + if (!card.priority.empty()) { + ImVec4 col_p(0.6f, 0.6f, 0.6f, 1); + if (card.priority == "high") col_p = {0.95f, 0.55f, 0.2f, 1}; + else if (card.priority == "critical") col_p = {0.95f, 0.25f, 0.25f, 1}; + else if (card.priority == "low") col_p = {0.45f, 0.7f, 0.95f, 1}; + ImGui::TextColored(col_p, TI_FLAG " %s", card.priority.c_str()); + } + if (!card.assignee.empty()) { + ImGui::SameLine(); + ImGui::TextDisabled(TI_USER " %s", card.assignee.c_str()); + } + ImGui::EndChild(); + if (ImGui::IsItemHovered() && ImGui::IsItemClicked(ImGuiMouseButton_Right)) { + ImGui::OpenPopup("##card_ctx"); + } + if (ImGui::BeginPopup("##card_ctx")) { + ImGui::TextDisabled("Move to:"); + for (const auto& tgt : s.columns) { + if (tgt.id == card.column_id) continue; + if (ImGui::MenuItem(tgt.name.c_str())) { + std::string err; + if (!move_card(s.cfg, card.id, tgt.id, err)) + s.last_refresh_error = "move: " + err; + else refresh_data(s); + } + } + ImGui::Separator(); + if (ImGui::MenuItem(TI_PLAYER_PLAY " Launch agent workflow")) { + std::string run_id, err; + if (!launch_workflow(s.cfg, card.id, run_id, err)) + s.last_refresh_error = "launch: " + err; + } + ImGui::EndPopup(); + } + ImGui::PopID(); + } + ImGui::EndChild(); + } + } + ImGui::EndChild(); + + ImGui::End(); +} + +} // namespace kanban_cpp diff --git a/panel_calendar.cpp b/panel_calendar.cpp new file mode 100644 index 0000000..c0e3851 --- /dev/null +++ b/panel_calendar.cpp @@ -0,0 +1,97 @@ +// panel_calendar.cpp — MVP monthly calendar view. +// +// Renders a simple 7-column grid for the current month with cards bucketed +// by `due_date`. No navigation, no editing — that's tracked as TODO in +// app.md ## Gotchas. +#include "panels.h" +#include "core/icons_tabler.h" + +#include +#include + +namespace kanban_cpp { + +void draw_calendar(AppState& s, bool* p_open) { + if (!ImGui::Begin(TI_CALENDAR " Calendar", p_open)) { + ImGui::End(); + return; + } + + std::time_t now = std::time(nullptr); + std::tm tm_now; +#ifdef _WIN32 + localtime_s(&tm_now, &now); +#else + localtime_r(&now, &tm_now); +#endif + + // Heading + static const char* months[] = {"Enero","Febrero","Marzo","Abril","Mayo","Junio", + "Julio","Agosto","Septiembre","Octubre","Noviembre","Diciembre"}; + ImGui::Text("%s %d", months[tm_now.tm_mon], tm_now.tm_year + 1900); + ImGui::TextDisabled("(MVP estatico — TODO: navegacion + filtros)"); + ImGui::Separator(); + + // First day of current month + days in month + std::tm tm_first = tm_now; + tm_first.tm_mday = 1; + std::mktime(&tm_first); + int first_wday = tm_first.tm_wday; // 0 = Sunday + int first_wday_mon = (first_wday + 6) % 7; // 0 = Monday (ES convention) + + int days_in_month = 31; + { + std::tm tm_test = tm_now; + tm_test.tm_mday = 32; + std::mktime(&tm_test); + days_in_month = 32 - tm_test.tm_mday; + } + + // Grid 7 cols + if (ImGui::BeginTable("##cal", 7, ImGuiTableFlags_Borders | ImGuiTableFlags_SizingStretchSame)) { + const char* wdays[] = {"L","M","X","J","V","S","D"}; + for (int i = 0; i < 7; ++i) ImGui::TableSetupColumn(wdays[i]); + ImGui::TableHeadersRow(); + + int day = 1; + int total_cells = first_wday_mon + days_in_month; + int rows = (total_cells + 6) / 7; + int cell_index = 0; + + for (int r = 0; r < rows; ++r) { + ImGui::TableNextRow(); + for (int c = 0; c < 7; ++c) { + ImGui::TableSetColumnIndex(c); + if (cell_index < first_wday_mon || day > days_in_month) { + ImGui::TextDisabled(" "); + } else { + ImGui::Text("%d", day); + // Count cards whose due_date falls in this day. + int hits = 0; + for (const auto& card : s.cards) { + if (card.due_date == 0) continue; + std::time_t cd = (std::time_t)card.due_date; + std::tm tmc; +#ifdef _WIN32 + localtime_s(&tmc, &cd); +#else + localtime_r(&cd, &tmc); +#endif + if (tmc.tm_year == tm_now.tm_year && tmc.tm_mon == tm_now.tm_mon + && tmc.tm_mday == day) ++hits; + } + if (hits > 0) + ImGui::TextColored(ImVec4(0.6f, 0.55f, 0.95f, 1.0f), + TI_FLAG " %d", hits); + ++day; + } + ++cell_index; + } + } + ImGui::EndTable(); + } + + ImGui::End(); +} + +} // namespace kanban_cpp diff --git a/panel_dashboard.cpp b/panel_dashboard.cpp new file mode 100644 index 0000000..d1a10f9 --- /dev/null +++ b/panel_dashboard.cpp @@ -0,0 +1,72 @@ +// panel_dashboard.cpp — KPI grid using kpi_card + sparkline from the registry. +#include "panels.h" +#include "core/icons_tabler.h" +#include "viz/kpi_card.h" +#include "viz/sparkline.h" + +#include +#include +#include + +namespace kanban_cpp { + +namespace { + +float pct_by_status(const std::vector& cards, const std::string& status) { + if (cards.empty()) return 0.0f; + int hits = 0; + for (const auto& c : cards) if (c.status == status) ++hits; + return 100.0f * static_cast(hits) / static_cast(cards.size()); +} + +} // namespace + +void draw_dashboard(AppState& s, bool* p_open) { + if (!ImGui::Begin(TI_DASHBOARD " Dashboard", p_open)) { + ImGui::End(); + return; + } + + ImGui::TextDisabled("KPIs sinteticos (TODO: backend /api/stats endpoint)"); + ImGui::Separator(); + + // Snapshot counts + int total = static_cast(s.cards.size()); + std::map by_priority; + std::map by_status; + for (const auto& c : s.cards) { + if (!c.priority.empty()) by_priority[c.priority]++; + if (!c.status.empty()) by_status[c.status]++; + } + + // Fake history for sparkline — until backend wires real time-series. + static float hist_total[12] = {3,4,5,5,7,8,9,8,10,11,12,13}; + static float hist_doing[12] = {1,1,2,2,3,3,4,4,5,5,6,6}; + + // KPI grid (use Columns for a quick 3-up layout) + ImGui::Columns(3, "##kpi_cols", false); + + kpi_card("Total cards", static_cast(total), 0.0f, + hist_total, 12, "%.0f", TI_LAYOUT_KANBAN); + ImGui::NextColumn(); + + kpi_card("Doing now", static_cast(by_status["doing"]), 0.0f, + hist_doing, 12, "%.0f", TI_PLAYER_PLAY); + ImGui::NextColumn(); + + kpi_card("Critical", static_cast(by_priority["critical"]), 0.0f, + nullptr, 0, "%.0f", TI_FLAG); + + ImGui::Columns(1); + ImGui::Separator(); + + // Status breakdown + ImGui::Text("Status breakdown"); + for (const auto& kv : by_status) { + ImGui::Text(" %-12s %d", kv.first.c_str(), kv.second); + } + + ImGui::End(); +} + +} // namespace kanban_cpp diff --git a/panel_dod.cpp b/panel_dod.cpp new file mode 100644 index 0000000..a22d61e --- /dev/null +++ b/panel_dod.cpp @@ -0,0 +1,43 @@ +// panel_dod.cpp — DoD evidence inspector wrapping the registry panel. +// +// MVP: uses a synthetic in-memory DodPanelState so the panel renders without +// the agent_runner_api wired up. When that API exposes /api/dod_items + +// /api/dod_evidences endpoints, this will fetch them like panel_agent_runs. +#include "panels.h" +#include "core/icons_tabler.h" +#include "viz/dod_evidence_panel.h" + +#include + +namespace kanban_cpp { + +namespace { + +fn_viz::DodPanelState& state_singleton() { + static fn_viz::DodPanelState st; + static bool inited = false; + if (!inited) { + st.run_id = "(none)"; + // Empty until wired to backend. Helpers count zeros gracefully. + inited = true; + } + return st; +} + +} // namespace + +void draw_dod(AppState& /*s*/, bool* p_open) { + if (!ImGui::Begin(TI_LIST_CHECK " DoD inspector", p_open)) { + ImGui::End(); + return; + } + + auto& st = state_singleton(); + ImGui::TextDisabled("run_id: %s (TODO: wire to agent_runner_api)", st.run_id.c_str()); + ImGui::Separator(); + fn_viz::render_dod_evidence_panel(st); + + ImGui::End(); +} + +} // namespace kanban_cpp diff --git a/panel_worktrees.cpp b/panel_worktrees.cpp new file mode 100644 index 0000000..781f30c --- /dev/null +++ b/panel_worktrees.cpp @@ -0,0 +1,91 @@ +// panel_worktrees.cpp — lists git worktrees via `git worktree list --porcelain`. +// +// Read-only MVP: shows path, head, branch. Future work: create/remove from +// inside the panel (TODO in app.md ## Gotchas). +#include "panels.h" +#include "core/icons_tabler.h" + +#include +#include +#include +#include +#include + +namespace kanban_cpp { + +namespace { + +struct WT { + std::string path; + std::string head; + std::string branch; +}; + +std::vector scan_worktrees() { + std::vector out; +#ifdef _WIN32 + FILE* fp = _popen("git worktree list --porcelain 2>nul", "r"); +#else + FILE* fp = popen("git worktree list --porcelain 2>/dev/null", "r"); +#endif + if (!fp) return out; + std::array buf; + WT cur; + while (std::fgets(buf.data(), static_cast(buf.size()), fp)) { + std::string line(buf.data()); + while (!line.empty() && (line.back() == '\n' || line.back() == '\r')) line.pop_back(); + if (line.empty()) { + if (!cur.path.empty()) out.push_back(cur); + cur = WT(); + continue; + } + if (line.rfind("worktree ", 0) == 0) cur.path = line.substr(9); + else if (line.rfind("HEAD ", 0) == 0) cur.head = line.substr(5); + else if (line.rfind("branch ", 0) == 0) cur.branch = line.substr(7); + } + if (!cur.path.empty()) out.push_back(cur); +#ifdef _WIN32 + _pclose(fp); +#else + pclose(fp); +#endif + return out; +} + +} // namespace + +void draw_worktrees(AppState& /*s*/, bool* p_open) { + if (!ImGui::Begin(TI_GIT_BRANCH " Worktrees", p_open)) { + ImGui::End(); + return; + } + + static std::vector wts; + static bool first = true; + if (first) { wts = scan_worktrees(); first = false; } + if (ImGui::Button(TI_REFRESH " Rescan")) wts = scan_worktrees(); + ImGui::SameLine(); + ImGui::TextDisabled("%zu worktrees", wts.size()); + ImGui::Separator(); + + if (ImGui::BeginTable("##wts", 3, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg)) { + ImGui::TableSetupColumn("Branch"); + ImGui::TableSetupColumn("HEAD"); + ImGui::TableSetupColumn("Path"); + ImGui::TableHeadersRow(); + for (const auto& w : wts) { + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::TextUnformatted(w.branch.empty() ? "(detached)" : w.branch.c_str()); + ImGui::TableSetColumnIndex(1); + ImGui::TextUnformatted(w.head.substr(0, 10).c_str()); + ImGui::TableSetColumnIndex(2); + ImGui::TextUnformatted(w.path.c_str()); + } + ImGui::EndTable(); + } + + ImGui::End(); +} + +} // namespace kanban_cpp diff --git a/panels.h b/panels.h new file mode 100644 index 0000000..b435139 --- /dev/null +++ b/panels.h @@ -0,0 +1,31 @@ +// panels.h — Panel draw functions for kanban_cpp. +// +// Each draw_* expects to be called inside an active ImGui frame; it issues +// its own ImGui::Begin/End block guarded by the supplied bool*. +#pragma once + +#include "data.h" + +namespace kanban_cpp { + +// Shared app state passed to every panel. Owned by main.cpp. +struct AppState { + ClientConfig cfg; + std::vector cards; + std::vector columns; + std::string last_refresh_error; + int64_t last_refresh_ts = 0; + bool backend_ok = false; +}; + +void draw_board (AppState& s, bool* p_open); +void draw_calendar (AppState& s, bool* p_open); +void draw_dashboard (AppState& s, bool* p_open); +void draw_agent_runs(AppState& s, bool* p_open); +void draw_worktrees (AppState& s, bool* p_open); +void draw_dod (AppState& s, bool* p_open); + +// Polls the backend for cards/columns; updates s.last_refresh_*. +void refresh_data(AppState& s); + +} // namespace kanban_cpp