feat(kanban): deadlines en cards (context menu, badges, calendario, history)
- migration 009 + columna deadline TEXT en cards - backend: CardPatch.HasDeadline, eventos deadline_set/deadline_cleared - KanbanCard: menu derecho con DatePicker, badge countdown con colores por ratio (azul>=50%, amarillo<50%, rojo<10%, red.9 overdue) - App.tsx: filtro "Con deadline", handleSetCardDeadline optimista, jump-to-card + highlight - CalendarView: popover por dia con seq_num + titulo, click navega a card en tablero - HistoryModal: render eventos deadline_set/deadline_cleared - .gitignore: *.log Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+152
@@ -0,0 +1,152 @@
|
||||
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) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
+327
@@ -0,0 +1,327 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"fn-registry/functions/infra"
|
||||
)
|
||||
|
||||
const chatSystemPrompt = `Eres el asistente del tablero kanban. Tu trabajo es responder al usuario y, cuando pida cambios, modificar el tablero llamando a tools.
|
||||
|
||||
Cuando necesites modificar el tablero, responde EXCLUSIVAMENTE con un bloque <actions>...</actions> que contenga JSON valido (un array de acciones). Sin texto antes ni despues.
|
||||
|
||||
Ejemplo:
|
||||
<actions>
|
||||
[
|
||||
{"tool": "create_card", "input": {"column_id": "abc123", "requester": "Lucas", "title": "Revisar PR", "description": ""}},
|
||||
{"tool": "rename_column", "input": {"id": "def456", "name": "En curso"}}
|
||||
]
|
||||
</actions>
|
||||
|
||||
Tools disponibles (todas con sus inputs):
|
||||
- list_board {} -> {columns, cards}
|
||||
- create_column {name}
|
||||
- update_column {id, name?, location?, width?, wip_limit?, is_done?} // location: "board" | "sidebar". width: 200..800 px. wip_limit: max tarjetas (0 = sin limite). is_done: marca columna como terminal (cards dentro se cuentan como completadas para metricas y se muestran tachadas).
|
||||
- delete_column {id}
|
||||
- reorder_columns {ids:[...]}
|
||||
- create_card {column_id, requester?, title, description?}
|
||||
- update_card {id, requester?, title?, description?, color?, locked?, assignee_id?} // color: "blue", "teal", "violet", "pink", "orange", "green", "yellow", "red", "" (default). locked: true bloquea la tarjeta (no se puede mover entre columnas hasta desbloquear). assignee_id: ID del usuario asignado o null para desasignar.
|
||||
- delete_card {id}
|
||||
- move_card {id, column_id, ordered_ids?} // si omites ordered_ids la tarjeta se anade al final
|
||||
- card_history {id}
|
||||
- find_cards {query?, column_id?, requester?}
|
||||
- list_users {} -> [{id, username, display_name}]
|
||||
- assign_card {id, assignee_id} // alias rapido de update_card. assignee_id puede ser null para desasignar.
|
||||
|
||||
Si el usuario solo conversa o pide informacion (sin pedir cambios), responde texto natural en markdown SIN bloque <actions>.
|
||||
|
||||
Para resolver IDs a partir de nombres, mira el board_state que viene al final del prompt del usuario. NO inventes IDs.
|
||||
|
||||
LOOP ITERATIVO: Despues de aplicar tus acciones, el sistema te volvera a llamar con:
|
||||
- Los resultados de las tool calls anteriores (incluyendo IDs reales de columnas/tarjetas creadas).
|
||||
- El board_state actualizado.
|
||||
- Tu mensaje de usuario original.
|
||||
|
||||
Cuando recibas resultados de iteraciones anteriores, USA LOS IDs REALES devueltos en lugar de inventar placeholders. Continua emitiendo mas <actions> hasta completar la tarea.
|
||||
|
||||
Cuando hayas terminado COMPLETAMENTE la tarea, responde texto natural (markdown) SIN bloque <actions> — eso señala el fin del loop.`
|
||||
|
||||
const claudeBin = "claude"
|
||||
const claudeModel = "claude-sonnet-4-6"
|
||||
const claudeTimeout = 120 * time.Second
|
||||
const maxChatIterations = 8
|
||||
|
||||
type chatMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type chatRequest struct {
|
||||
Messages []chatMessage `json:"messages"`
|
||||
}
|
||||
|
||||
type chatResponse struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
BoardChanged bool `json:"board_changed"`
|
||||
ToolCalls []toolCallInfo `json:"tool_calls,omitempty"`
|
||||
}
|
||||
|
||||
type toolCallInfo struct {
|
||||
Tool string `json:"tool"`
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Iteration int `json:"iteration,omitempty"`
|
||||
// Result is included only for the loop's internal feedback to claude;
|
||||
// it is omitted from the JSON response sent to the frontend (clients
|
||||
// can use board_changed + reload to fetch fresh state).
|
||||
Result any `json:"-"`
|
||||
}
|
||||
|
||||
type claudeJSONResult struct {
|
||||
Type string `json:"type"`
|
||||
IsError bool `json:"is_error"`
|
||||
Result string `json:"result"`
|
||||
StopReason string `json:"stop_reason"`
|
||||
}
|
||||
|
||||
// runClaude invokes the `claude` CLI in print mode with the given system prompt
|
||||
// and user message. The board JSON is appended to the user message under a
|
||||
// `board_state` marker so the assistant can resolve names to IDs.
|
||||
//
|
||||
// stdin: the user-facing prompt (history flattened).
|
||||
// returns: assistant's text reply.
|
||||
func runClaude(ctx context.Context, systemPrompt, userInput, boardJSON, workdir string) (string, error) {
|
||||
if _, err := exec.LookPath(claudeBin); err != nil {
|
||||
return "", errors.New("claude CLI not found in PATH")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, claudeTimeout)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, claudeBin,
|
||||
"-p",
|
||||
"--model", claudeModel,
|
||||
"--output-format", "json",
|
||||
"--no-session-persistence",
|
||||
"--tools", "",
|
||||
"--system-prompt", systemPrompt,
|
||||
)
|
||||
cmd.Dir = workdir
|
||||
|
||||
prompt := userInput
|
||||
if boardJSON != "" {
|
||||
prompt += "\n\n<board_state>\n" + boardJSON + "\n</board_state>\n"
|
||||
}
|
||||
cmd.Stdin = bytes.NewBufferString(prompt)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", fmt.Errorf("claude exec: %w (stderr: %s)", err, stderr.String())
|
||||
}
|
||||
|
||||
var res claudeJSONResult
|
||||
if err := json.Unmarshal(stdout.Bytes(), &res); err != nil {
|
||||
return "", fmt.Errorf("parse claude json: %w (raw: %s)", err, stdout.String())
|
||||
}
|
||||
if res.IsError {
|
||||
return "", fmt.Errorf("claude error: %s", res.Result)
|
||||
}
|
||||
return res.Result, nil
|
||||
}
|
||||
|
||||
// flattenMessages converts a chat history into a single text prompt for `claude -p`.
|
||||
// Format: lines of `Usuario: ...` / `Asistente: ...`. Last user message ends the prompt.
|
||||
func flattenMessages(msgs []chatMessage) string {
|
||||
var b bytes.Buffer
|
||||
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()
|
||||
}
|
||||
|
||||
func handleChat(db *DB, workdir string, logger *ChatLogger) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req chatRequest
|
||||
if err := infra.HTTPParseBody(r, &req, 1<<20); err != nil {
|
||||
infra.HTTPErrorResponse(w, infra.HTTPError{Status: 400, Code: "bad_request", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
if len(req.Messages) == 0 {
|
||||
infra.HTTPErrorResponse(w, infra.HTTPError{Status: 400, Code: "bad_request", Message: "messages required"})
|
||||
return
|
||||
}
|
||||
|
||||
baseUserInput := flattenMessages(req.Messages)
|
||||
allCalls := []toolCallInfo{}
|
||||
var finalText string
|
||||
boardChanged := false
|
||||
|
||||
for iter := 1; iter <= maxChatIterations; iter++ {
|
||||
boardJSON, err := boardSnapshot(db)
|
||||
if err != nil {
|
||||
infra.HTTPErrorResponse(w, infra.HTTPError{Status: 500, Code: "internal", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
prompt := buildIterationPrompt(baseUserInput, allCalls, iter)
|
||||
|
||||
assistantText, err := runClaude(r.Context(), chatSystemPrompt, prompt, boardJSON, workdir)
|
||||
if err != nil {
|
||||
infra.HTTPErrorResponse(w, infra.HTTPError{Status: 500, Code: "claude_error", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
actionsJSON, stripped, found := extractActions(assistantText)
|
||||
if !found {
|
||||
finalText = assistantText
|
||||
break
|
||||
}
|
||||
|
||||
calls, changed := applyActions(db, actionsJSON, logger)
|
||||
for i := range calls {
|
||||
calls[i].Iteration = iter
|
||||
}
|
||||
allCalls = append(allCalls, calls...)
|
||||
if changed {
|
||||
boardChanged = true
|
||||
}
|
||||
|
||||
finalText = stripped // tentative; overwritten if next iter responds free text
|
||||
if iter == maxChatIterations {
|
||||
finalText = strings.TrimSpace(stripped + "\n\n_Limite de iteraciones alcanzado._")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Strip Result fields before serializing (not exported but defensive).
|
||||
respCalls := make([]toolCallInfo, len(allCalls))
|
||||
for i, c := range allCalls {
|
||||
respCalls[i] = toolCallInfo{Tool: c.Tool, OK: c.OK, Error: c.Error, Iteration: c.Iteration}
|
||||
}
|
||||
resp := chatResponse{
|
||||
Role: "assistant",
|
||||
Content: finalText,
|
||||
ToolCalls: respCalls,
|
||||
BoardChanged: boardChanged,
|
||||
}
|
||||
if resp.Content == "" {
|
||||
resp.Content = summarizeCalls(respCalls)
|
||||
}
|
||||
infra.HTTPJSONResponse(w, http.StatusOK, resp)
|
||||
}
|
||||
}
|
||||
|
||||
// buildIterationPrompt composes the user prompt for iteration N.
|
||||
// Iteration 1 = original user input; later iterations also include a summary
|
||||
// of previous tool calls so the assistant can use real IDs.
|
||||
func buildIterationPrompt(baseUserInput string, prevCalls []toolCallInfo, iter int) string {
|
||||
if iter == 1 || len(prevCalls) == 0 {
|
||||
return baseUserInput
|
||||
}
|
||||
var b bytes.Buffer
|
||||
b.WriteString(baseUserInput)
|
||||
b.WriteString("\n[Resultados de iteraciones anteriores]\n")
|
||||
for _, c := range prevCalls {
|
||||
if c.OK {
|
||||
summary := summarizeResult(c.Result)
|
||||
fmt.Fprintf(&b, "- iter %d %s: ok %s\n", c.Iteration, c.Tool, summary)
|
||||
} else {
|
||||
fmt.Fprintf(&b, "- iter %d %s: ERROR %s\n", c.Iteration, c.Tool, c.Error)
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(&b, "\n[Iteracion %d] Continua con las acciones pendientes. Si terminaste, responde texto natural sin <actions>.\n", iter)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
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.MarshalIndent(map[string]any{"columns": cols, "cards": cards}, "", " ")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
func applyActions(db *DB, actionsJSON string, logger *ChatLogger) ([]toolCallInfo, bool) {
|
||||
var actions []struct {
|
||||
Tool string `json:"tool"`
|
||||
Input json.RawMessage `json:"input"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(actionsJSON), &actions); err != nil {
|
||||
return []toolCallInfo{{Tool: "<parse>", OK: false, Error: err.Error()}}, false
|
||||
}
|
||||
|
||||
results := make([]toolCallInfo, 0, len(actions))
|
||||
changed := false
|
||||
for _, a := range actions {
|
||||
if err := validateToolName(a.Tool); err != nil {
|
||||
info := toolCallInfo{Tool: a.Tool, OK: false, Error: err.Error()}
|
||||
results = append(results, info)
|
||||
logger.Log(a.Tool, a.Input, ToolResult{OK: false, Error: err.Error()})
|
||||
continue
|
||||
}
|
||||
res := executeTool(db, a.Tool, a.Input)
|
||||
logger.Log(a.Tool, a.Input, res)
|
||||
info := toolCallInfo{Tool: a.Tool, OK: res.OK, Result: res.Result}
|
||||
if !res.OK {
|
||||
info.Error = res.Error
|
||||
} else if toolMutates(a.Tool) {
|
||||
changed = true
|
||||
}
|
||||
results = append(results, info)
|
||||
}
|
||||
return results, changed
|
||||
}
|
||||
|
||||
func summarizeCalls(calls []toolCallInfo) string {
|
||||
if len(calls) == 0 {
|
||||
return ""
|
||||
}
|
||||
var b bytes.Buffer
|
||||
b.WriteString("Acciones aplicadas:\n")
|
||||
for _, c := range calls {
|
||||
if c.OK {
|
||||
fmt.Fprintf(&b, "- %s: ok\n", c.Tool)
|
||||
} else {
|
||||
fmt.Fprintf(&b, "- %s: error (%s)\n", c.Tool, c.Error)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// chatWorkdir resolves an absolute working directory for `claude -p` (avoids
|
||||
// inheriting CLAUDE.md from parent directories with unrelated context).
|
||||
func chatWorkdir(dbPath string) string {
|
||||
abs, err := filepath.Abs(dbPath)
|
||||
if err != nil {
|
||||
return "."
|
||||
}
|
||||
return filepath.Dir(abs)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
+1033
File diff suppressed because it is too large
Load Diff
+1136
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Kanban</title>
|
||||
<script type="module" crossorigin src="/assets/index-BKxzRoLi.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-nR9uJgze.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 => ../../..
|
||||
+176
@@ -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=
|
||||
@@ -0,0 +1,382 @@
|
||||
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}/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) []infra.Route {
|
||||
return []infra.Route{
|
||||
{Method: "POST", Path: "/api/auth/register", Handler: handleRegister(db)},
|
||||
{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: "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/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)
|
||||
}
|
||||
}
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
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() {
|
||||
flags := flag.NewFlagSet("kanban", flag.ExitOnError)
|
||||
port := flags.Int("port", 8095, "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)")
|
||||
flags.Parse(os.Args[1:])
|
||||
|
||||
db, err := openDB(*dbPath)
|
||||
if err != nil {
|
||||
log.Fatalf("open db: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
bootstrapAdmin(db, *initialAdmin)
|
||||
startSessionCleanup(db)
|
||||
|
||||
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))
|
||||
|
||||
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/", "/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")
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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 '[]';
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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 '';
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
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)
|
||||
}
|
||||
|
||||
// chatActionsRegex matches an <actions>...</actions> block (DOTALL mode).
|
||||
// Used by chat.go to extract tool invocations from the assistant's response.
|
||||
var actionsBlockMarker = struct{ Open, Close string }{Open: "<actions>", Close: "</actions>"}
|
||||
|
||||
func extractActions(text string) (jsonBlock string, stripped string, found bool) {
|
||||
openIdx := strings.Index(text, actionsBlockMarker.Open)
|
||||
if openIdx < 0 {
|
||||
return "", text, false
|
||||
}
|
||||
closeIdx := strings.Index(text[openIdx:], actionsBlockMarker.Close)
|
||||
if closeIdx < 0 {
|
||||
return "", text, false
|
||||
}
|
||||
closeIdx += openIdx
|
||||
jsonBlock = strings.TrimSpace(text[openIdx+len(actionsBlockMarker.Open) : closeIdx])
|
||||
before := strings.TrimRight(text[:openIdx], " \n\t")
|
||||
after := strings.TrimLeft(text[closeIdx+len(actionsBlockMarker.Close):], " \n\t")
|
||||
stripped = strings.TrimSpace(before + "\n" + after)
|
||||
return jsonBlock, stripped, true
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
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")
|
||||
}
|
||||
|
||||
// --- extractActions ---
|
||||
|
||||
func TestExtractActions(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
stripOK string
|
||||
found bool
|
||||
}{
|
||||
{"with block", "Hola\n<actions>[{\"tool\":\"x\"}]</actions>\nHecho", `[{"tool":"x"}]`, "Hola\nHecho", true},
|
||||
{"only block", "<actions>[]</actions>", `[]`, "", true},
|
||||
{"no block", "Solo texto", "", "Solo texto", false},
|
||||
{"unclosed", "<actions>foo", "", "<actions>foo", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got, stripped, found := extractActions(c.in)
|
||||
if found != c.found {
|
||||
t.Fatalf("found = %v want %v", found, c.found)
|
||||
}
|
||||
if got != c.want {
|
||||
t.Fatalf("got %q want %q", got, c.want)
|
||||
}
|
||||
if stripped != c.stripOK {
|
||||
t.Fatalf("stripped = %q want %q", stripped, c.stripOK)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user