c3cc42b350
Backend:
- migration 018: cards.jira_last_status / sync_at / error (estado persistido del ultimo
sync para render UI sin polling Jira).
- Dispatcher: sync.Map inflight para 'yellow' realtime + persistencia de exito/fallo
en cards tras cada dispatch attempt.
- GET /api/cards/{id}/jira-sync: devuelve {jira_key, last_status, last_sync_at,
last_error, inflight, issue_url} para el tooltip del indicador.
- GET /api/jira/issues: lista issues del board 33 con flag already_imported +
mapped_column_id (reverse status_map). Filtros include_imported, limit.
- POST /api/jira/import: multi-key. Cada issue -> CreateCard + setCardJiraKey +
seed jira_last_status. Cae en columna mapeada por status, o en fallback_column_id.
ADF de description extraido a texto plano.
Frontend:
- JiraSyncIndicator: dot gris/amarillo/verde/rojo bajo IconDotsVertical de cada card.
Mantine HoverCard con jira_key, status, last_sync, last_error, link 'Abrir en Jira'.
Poll cada 10s, refresh-tick opcional.
- KanbanCard: agrupa menu + indicator en Stack vertical (indicator debajo de los 3 dots).
- ImportJiraModal: modal admin con tabla de issues. Checkbox por fila, filtro por texto,
toggle 'mostrar ya importadas', Select de columna fallback. Tras import recarga board.
- App.tsx: nueva entrada de menu 'Importar de Jira' (admin) y ImportJiraModal mounted.
Backend tests siguen verdes (test mock cubre transitions endpoints).
Frontend pnpm build OK.
268 lines
7.7 KiB
Go
268 lines
7.7 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"fn-registry/functions/infra"
|
|
)
|
|
|
|
// requireAdmin gates a handler so only users with users.is_admin = 1 can
|
|
// reach it. Non-admins get a 403. Anonymous callers get a 401.
|
|
func requireAdmin(db *DB, next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
uid, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
|
|
if uid == "" {
|
|
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "session required"})
|
|
return
|
|
}
|
|
ok, err := db.IsAdmin(uid)
|
|
if err != nil {
|
|
serverError(w, err)
|
|
return
|
|
}
|
|
if !ok {
|
|
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusForbidden, Code: "forbidden", Message: "admin required"})
|
|
return
|
|
}
|
|
next(w, r)
|
|
}
|
|
}
|
|
|
|
// publicModule strips secrets out of the config before responding. The
|
|
// API token is never returned to the client after it has been stored.
|
|
func publicModule(m Module) Module {
|
|
clone := m
|
|
if clone.Config != nil {
|
|
cleaned := JSONValue{}
|
|
for k, v := range clone.Config {
|
|
if strings.Contains(strings.ToLower(k), "token") || strings.Contains(strings.ToLower(k), "password") || strings.Contains(strings.ToLower(k), "secret") {
|
|
cleaned[k] = "***"
|
|
} else {
|
|
cleaned[k] = v
|
|
}
|
|
}
|
|
clone.Config = cleaned
|
|
}
|
|
return clone
|
|
}
|
|
|
|
func handleListModules(db *DB) http.HandlerFunc {
|
|
return requireAdmin(db, func(w http.ResponseWriter, r *http.Request) {
|
|
mods, err := db.listModulesAll()
|
|
if err != nil {
|
|
serverError(w, err)
|
|
return
|
|
}
|
|
out := make([]Module, 0, len(mods))
|
|
for _, m := range mods {
|
|
out = append(out, publicModule(m))
|
|
}
|
|
infra.HTTPJSONResponse(w, http.StatusOK, out)
|
|
})
|
|
}
|
|
|
|
type modulePayload struct {
|
|
Name string `json:"name"`
|
|
Kind string `json:"kind"`
|
|
Enabled bool `json:"enabled"`
|
|
EventFilter []string `json:"event_filter"`
|
|
Config JSONValue `json:"config"`
|
|
}
|
|
|
|
func handleCreateModule(db *DB) http.HandlerFunc {
|
|
return requireAdmin(db, func(w http.ResponseWriter, r *http.Request) {
|
|
var body modulePayload
|
|
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
|
|
badRequest(w, err.Error())
|
|
return
|
|
}
|
|
if body.Name == "" || body.Kind == "" {
|
|
badRequest(w, "name and kind required")
|
|
return
|
|
}
|
|
m := &Module{
|
|
Name: body.Name, Kind: body.Kind, Enabled: body.Enabled,
|
|
EventFilter: body.EventFilter, Config: body.Config,
|
|
}
|
|
if err := db.saveModule(m); err != nil {
|
|
serverError(w, err)
|
|
return
|
|
}
|
|
infra.HTTPJSONResponse(w, http.StatusCreated, publicModule(*m))
|
|
})
|
|
}
|
|
|
|
func handleUpdateModule(db *DB) http.HandlerFunc {
|
|
return requireAdmin(db, func(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
existing, err := db.getModule(id)
|
|
if err != nil {
|
|
notFound(w, "module not found")
|
|
return
|
|
}
|
|
// Partial body: preserve fields the client did not include. We rely
|
|
// on a generic map to detect omitted vs explicit-null because PATCH
|
|
// callers do not always send the full record.
|
|
var raw map[string]json.RawMessage
|
|
if err := infra.HTTPParseBody(r, &raw, maxBodyBytes); err != nil {
|
|
badRequest(w, err.Error())
|
|
return
|
|
}
|
|
decode := func(key string, into interface{}) {
|
|
if v, ok := raw[key]; ok {
|
|
_ = json.Unmarshal(v, into)
|
|
}
|
|
}
|
|
decode("name", &existing.Name)
|
|
decode("kind", &existing.Kind)
|
|
decode("enabled", &existing.Enabled)
|
|
if v, ok := raw["event_filter"]; ok {
|
|
_ = json.Unmarshal(v, &existing.EventFilter)
|
|
}
|
|
if v, ok := raw["config"]; ok {
|
|
var cfg JSONValue
|
|
_ = json.Unmarshal(v, &cfg)
|
|
// Re-inject masked fields the UI left as "***" so a partial
|
|
// edit does not nuke stored secrets.
|
|
merged := JSONValue{}
|
|
for k, val := range existing.Config {
|
|
merged[k] = val
|
|
}
|
|
for k, val := range cfg {
|
|
if s, isStr := val.(string); isStr && s == "***" {
|
|
continue
|
|
}
|
|
merged[k] = val
|
|
}
|
|
existing.Config = merged
|
|
}
|
|
if err := db.saveModule(existing); err != nil {
|
|
serverError(w, err)
|
|
return
|
|
}
|
|
infra.HTTPJSONResponse(w, http.StatusOK, publicModule(*existing))
|
|
})
|
|
}
|
|
|
|
func handleDeleteModule(db *DB) http.HandlerFunc {
|
|
return requireAdmin(db, func(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
if err := db.deleteModule(id); err != nil {
|
|
serverError(w, err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
})
|
|
}
|
|
|
|
func handleModuleLogs(db *DB) http.HandlerFunc {
|
|
return requireAdmin(db, func(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
limit := 100
|
|
if v := r.URL.Query().Get("limit"); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 1000 {
|
|
limit = n
|
|
}
|
|
}
|
|
out, err := db.listModuleLogs(id, limit)
|
|
if err != nil {
|
|
serverError(w, err)
|
|
return
|
|
}
|
|
infra.HTTPJSONResponse(w, http.StatusOK, out)
|
|
})
|
|
}
|
|
|
|
// handleTestModule executes the kind-specific test_connection probe with
|
|
// the *current stored config* (or with an incoming config payload, for
|
|
// pre-save validation). Returns {ok, status, error} regardless of outcome
|
|
// so the UI can show a useful message.
|
|
func handleTestModule(db *DB, dispatcher *Dispatcher) http.HandlerFunc {
|
|
return requireAdmin(db, func(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
var m *Module
|
|
if id == "draft" {
|
|
// Pre-save test path: caller supplies a full module payload.
|
|
var body modulePayload
|
|
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
|
|
badRequest(w, err.Error())
|
|
return
|
|
}
|
|
m = &Module{Kind: body.Kind, Config: body.Config}
|
|
} else {
|
|
got, err := db.getModule(id)
|
|
if err != nil {
|
|
notFound(w, "module not found")
|
|
return
|
|
}
|
|
m = got
|
|
}
|
|
h, ok := dispatcher.handlers[m.Kind]
|
|
if !ok {
|
|
infra.HTTPJSONResponse(w, http.StatusOK, map[string]interface{}{
|
|
"ok": false, "status": 0, "error": "unknown kind: " + m.Kind,
|
|
})
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), moduleHTTPTimeout)
|
|
defer cancel()
|
|
start := time.Now()
|
|
status, err := h.TestConnection(ctx, *m)
|
|
resp := map[string]interface{}{
|
|
"ok": err == nil,
|
|
"status": status,
|
|
"duration_ms": int(time.Since(start).Milliseconds()),
|
|
}
|
|
if err != nil {
|
|
resp["error"] = err.Error()
|
|
}
|
|
infra.HTTPJSONResponse(w, http.StatusOK, resp)
|
|
})
|
|
}
|
|
|
|
// handleCardJiraSync returns the per-card Jira sync state for the indicator
|
|
// tooltip. Reads cards.jira_last_* columns + dispatcher inflight map. The
|
|
// caller does not need admin: any authenticated user can see the state of
|
|
// their cards. Returns 200 + zero-valued state when the card has no link
|
|
// yet (so the UI can show the gray indicator without a special case).
|
|
func handleCardJiraSync(db *DB, dispatcher *Dispatcher) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
uid, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
|
|
if uid == "" {
|
|
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "session required"})
|
|
return
|
|
}
|
|
id := r.PathValue("id")
|
|
state, err := db.readCardJiraSync(id)
|
|
if err != nil {
|
|
notFound(w, "card not found")
|
|
return
|
|
}
|
|
state.Inflight = dispatcher.IsInflight(id)
|
|
// Resolve issue URL by reading any enabled jira module's base_url. We
|
|
// pick the first match because the kanban-jira link is conceptually
|
|
// 1:1 — multiple jira modules pointing at different projects would be
|
|
// a misconfiguration.
|
|
if state.JiraKey != "" {
|
|
if mods, err := db.listModulesEnabled(); err == nil {
|
|
for _, m := range mods {
|
|
if m.Kind != "jira" {
|
|
continue
|
|
}
|
|
cfg, perr := parseJiraConfig(m)
|
|
if perr == nil && cfg.BaseURL != "" {
|
|
state.IssueURL = cfg.BaseURL + "/browse/" + state.JiraKey
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
infra.HTTPJSONResponse(w, http.StatusOK, state)
|
|
}
|
|
}
|