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)) }) }