Files
kanban_cpp/backend/handlers.go
T
agent 255e8dcf71 feat: initial scaffold of kanban_cpp v2 (issue 0130)
Frontend C++ ImGui (main.cpp + 4 paneles) + backend Go (HTTP + SQLite + fsnotify + SSE).
Reusa parse/scan/watch funcs del registry (issue 0130a).
2026-05-22 22:19:47 +02:00

344 lines
9.9 KiB
Go

package main
import (
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"time"
"fn-registry/functions/infra"
)
func (s *Server) registerRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/health", s.handleHealth)
mux.HandleFunc("/api/issues", s.handleIssues)
mux.HandleFunc("/api/issues/", s.handleIssueByID)
mux.HandleFunc("/api/flows", s.handleFlows)
mux.HandleFunc("/api/flows/", s.handleFlowByID)
mux.HandleFunc("/api/meta", s.handleMeta)
mux.HandleFunc("/api/sse", s.handleSSE)
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func writeErr(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
var ni, nf int
s.db.QueryRow(`SELECT COUNT(*) FROM issues`).Scan(&ni)
s.db.QueryRow(`SELECT COUNT(*) FROM flows`).Scan(&nf)
writeJSON(w, 200, map[string]any{
"ok": true,
"version": "0.1.0",
"count_issues": ni,
"count_flows": nf,
"issues_dir": s.issuesDir,
"flows_dir": s.flowsDir,
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
}
func (s *Server) handleIssues(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeErr(w, 405, "method not allowed")
return
}
q := r.URL.Query()
where := []string{"1=1"}
args := []any{}
if v := q.Get("status"); v != "" {
where = append(where, "status=?")
args = append(args, v)
}
if v := q.Get("priority"); v != "" {
where = append(where, "priority=?")
args = append(args, v)
}
if v := q.Get("scope"); v != "" {
where = append(where, "scope=?")
args = append(args, v)
}
if v := q.Get("domain"); v != "" {
where = append(where, "domain_json LIKE ?")
args = append(args, "%\""+v+"\"%")
}
if v := q.Get("tag"); v != "" {
where = append(where, "tags_json LIKE ?")
args = append(args, "%\""+v+"\"%")
}
if v := q.Get("completed"); v != "" {
if v == "true" || v == "1" {
where = append(where, "completed=1")
} else {
where = append(where, "completed=0")
}
}
sql := "SELECT id,title,status,type,scope,priority,domain_json,tags_json,depends_json,blocks_json,related_json,flow_id,file_path,completed,created_at,updated_at FROM issues WHERE " + strings.Join(where, " AND ") + " ORDER BY id ASC"
rows, err := s.db.Query(sql, args...)
if err != nil {
writeErr(w, 500, err.Error())
return
}
defer rows.Close()
out := []map[string]any{}
for rows.Next() {
var (
id, title, status, typ, scope, priority string
domJ, tagJ, depJ, blkJ, relJ, flow, path string
completedInt int
createdAt, updatedAt string
)
if err := rows.Scan(&id, &title, &status, &typ, &scope, &priority, &domJ, &tagJ, &depJ, &blkJ, &relJ, &flow, &path, &completedInt, &createdAt, &updatedAt); err != nil {
writeErr(w, 500, err.Error())
return
}
out = append(out, map[string]any{
"id": id,
"title": title,
"status": status,
"type": typ,
"scope": scope,
"priority": priority,
"domain": parseJSONArr(domJ),
"tags": parseJSONArr(tagJ),
"depends": parseJSONArr(depJ),
"blocks": parseJSONArr(blkJ),
"related": parseJSONArr(relJ),
"flow": flow,
"file_path": path,
"completed": completedInt == 1,
"created": createdAt,
"updated": updatedAt,
})
}
writeJSON(w, 200, out)
}
func (s *Server) handleIssueByID(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, "/api/issues/")
if id == "" {
writeErr(w, 400, "missing id")
return
}
switch r.Method {
case "GET":
s.getIssue(w, id)
case "PATCH":
s.patchIssue(w, r, id)
default:
writeErr(w, 405, "method not allowed")
}
}
func (s *Server) getIssue(w http.ResponseWriter, id string) {
row := s.db.QueryRow(`SELECT id,title,status,type,scope,priority,domain_json,tags_json,depends_json,blocks_json,related_json,flow_id,body,file_path,completed,created_at,updated_at FROM issues WHERE id=?`, id)
var (
iid, title, status, typ, scope, priority string
domJ, tagJ, depJ, blkJ, relJ, flow, body, path string
completedInt int
createdAt, updatedAt string
)
if err := row.Scan(&iid, &title, &status, &typ, &scope, &priority, &domJ, &tagJ, &depJ, &blkJ, &relJ, &flow, &body, &path, &completedInt, &createdAt, &updatedAt); err != nil {
writeErr(w, 404, "not found")
return
}
writeJSON(w, 200, map[string]any{
"id": iid, "title": title, "status": status, "type": typ, "scope": scope, "priority": priority,
"domain": parseJSONArr(domJ), "tags": parseJSONArr(tagJ),
"depends": parseJSONArr(depJ), "blocks": parseJSONArr(blkJ), "related": parseJSONArr(relJ),
"flow": flow, "body": body, "file_path": path, "completed": completedInt == 1,
"created": createdAt, "updated": updatedAt,
})
}
func (s *Server) patchIssue(w http.ResponseWriter, r *http.Request, id string) {
var patch map[string]any
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
writeErr(w, 400, "bad json")
return
}
var filePath string
if err := s.db.QueryRow(`SELECT file_path FROM issues WHERE id=?`, id).Scan(&filePath); err != nil {
writeErr(w, 404, "not found")
return
}
iss, body, err := infra.ParseIssueMd(filePath)
if err != nil {
writeErr(w, 500, fmt.Sprintf("parse: %v", err))
return
}
applyPatch(&iss, patch)
iss.Updated = time.Now().UTC().Format("2006-01-02")
if err := infra.WriteIssueMd(filePath, iss, body); err != nil {
writeErr(w, 500, fmt.Sprintf("write: %v", err))
return
}
info, _ := os.Stat(filePath)
if info != nil {
iss.MtimeNs = info.ModTime().UnixNano()
}
iss.FilePath = filePath
iss.Completed = strings.Contains(filePath, "/completed/")
if err := s.upsertIssueRow(iss); err != nil {
writeErr(w, 500, err.Error())
return
}
s.hub.broadcast(SSEEvent{Type: "updated", ID: id, Path: filePath})
s.getIssue(w, id)
}
func applyPatch(iss *infra.Issue, patch map[string]any) {
if v, ok := patch["status"].(string); ok {
iss.Status = v
}
if v, ok := patch["priority"].(string); ok {
iss.Priority = v
}
if v, ok := patch["scope"].(string); ok {
iss.Scope = v
}
if v, ok := patch["title"].(string); ok {
iss.Title = v
}
if v, ok := patch["type"].(string); ok {
iss.Type = v
}
if v, ok := patch["flow"].(string); ok {
iss.Flow = v
}
for _, k := range []string{"domain", "tags", "depends", "blocks", "related"} {
if raw, ok := patch[k]; ok {
arr := []string{}
if xs, ok := raw.([]any); ok {
for _, x := range xs {
if s, ok := x.(string); ok {
arr = append(arr, s)
}
}
}
switch k {
case "domain":
iss.Domain = arr
case "tags":
iss.Tags = arr
case "depends":
iss.Depends = arr
case "blocks":
iss.Blocks = arr
case "related":
iss.Related = arr
}
}
}
}
func (s *Server) handleFlows(w http.ResponseWriter, r *http.Request) {
rows, err := s.db.Query(`SELECT id,title,status,kind,tags_json,file_path FROM flows ORDER BY id ASC`)
if err != nil {
writeErr(w, 500, err.Error())
return
}
defer rows.Close()
out := []map[string]any{}
for rows.Next() {
var id, title, status, kind, tagJ, path string
rows.Scan(&id, &title, &status, &kind, &tagJ, &path)
out = append(out, map[string]any{
"id": id, "title": title, "status": status, "kind": kind,
"tags": parseJSONArr(tagJ), "file_path": path,
})
}
writeJSON(w, 200, out)
}
func (s *Server) handleFlowByID(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, "/api/flows/")
if id == "" {
writeErr(w, 400, "missing id")
return
}
row := s.db.QueryRow(`SELECT id,title,status,kind,tags_json,body,file_path FROM flows WHERE id=?`, id)
var iid, title, status, kind, tagJ, body, path string
if err := row.Scan(&iid, &title, &status, &kind, &tagJ, &body, &path); err != nil {
writeErr(w, 404, "not found")
return
}
writeJSON(w, 200, map[string]any{
"id": iid, "title": title, "status": status, "kind": kind,
"tags": parseJSONArr(tagJ), "body": body, "file_path": path,
})
}
func (s *Server) handleMeta(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{
"statuses": []string{"pendiente", "in-progress", "bloqueado", "completado", "deferred", "descartado"},
"priorities": []string{"critica", "alta", "media", "baja"},
"scopes": []string{"registry-only", "app-scoped", "multi-app", "cross-stack"},
"types": []string{"feature", "bugfix", "refactor", "docs", "chore", "research", "infra", "app", "spike", "epic", "planning"},
})
}
func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
flusher, ok := w.(http.Flusher)
if !ok {
writeErr(w, 500, "streaming unsupported")
return
}
ch := s.hub.subscribe()
defer s.hub.unsubscribe(ch)
pingTick := time.NewTicker(15 * time.Second)
defer pingTick.Stop()
for {
select {
case <-r.Context().Done():
return
case ev := <-ch:
b, _ := json.Marshal(ev)
fmt.Fprintf(w, "data: %s\n\n", b)
flusher.Flush()
case <-pingTick.C:
fmt.Fprintf(w, ": ping\n\n")
flusher.Flush()
}
}
}
func parseJSONArr(s string) []string {
if s == "" {
return []string{}
}
var arr []string
if err := json.Unmarshal([]byte(s), &arr); err != nil {
return []string{}
}
return arr
}
func withMiddleware(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PATCH,OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type,Authorization")
if r.Method == "OPTIONS" {
w.WriteHeader(204)
return
}
start := time.Now()
h.ServeHTTP(w, r)
fmt.Printf("[%s] %s %s %s\n", time.Now().Format("15:04:05"), r.Method, r.URL.Path, time.Since(start))
})
}