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:
2026-05-09 03:45:36 +02:00
parent 5ba0254e57
commit 7ce227ddea
54 changed files with 3066 additions and 496 deletions
+152
View File
@@ -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
View File
@@ -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)
}
+86
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+13
View File
@@ -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>
+49
View File
@@ -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
View File
@@ -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=
+382
View File
@@ -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
View File
@@ -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")
}
+603
View File
@@ -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()
}
+51
View File
@@ -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);
+4
View File
@@ -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;
+9
View File
@@ -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);
+3
View File
@@ -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;
+2
View File
@@ -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 '';
+11
View File
@@ -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);
+7
View File
@@ -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;
+4
View File
@@ -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;
+94
View File
@@ -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)
}
}
+376
View File
@@ -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
}
+430
View File
@@ -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)
}
}
}
+129
View File
@@ -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
}