Files
egutierrez 0d8ec1e8e7 fix(jira): emit card.created so card creation syncs to Jira
handleCreateCard only published board.invalidated, which is not in the
module event filter, so the dispatcher dropped it and jiraHandler.create
never ran. Newly created cards therefore never produced a Jira issue,
unlike moves (card.moved) and chat (message.created) which already synced.

Emit card.created after assignee/tags are applied so the synced issue
carries them. board.invalidated is kept for the SPA refetch path. No loop
risk (card.created fires only from the HTTP handler) and no double-create
(board.invalidated stays out of the filter).
2026-06-01 15:25:25 +02:00

754 lines
25 KiB
Go

package main
import (
"log"
"net/http"
"strings"
"sync/atomic"
"time"
"fn-registry/functions/infra"
)
const maxBodyBytes = 1 << 20 // 1 MiB
// Auto-archive: cards en columnas Done con >30 dias se mueven al cajon.
// Issue 0092. Lo dispara handleGetBoard de forma "lazy" pero solo cada
// archiveSweepEvery minutos para no martillear el UPDATE.
const (
archiveAfter = 30 * 24 * time.Hour
archiveSweepEvery = 30 * time.Minute
)
var lastArchiveSweepNs atomic.Int64
func maybeAutoArchive(db *DB) {
now := time.Now().UnixNano()
last := lastArchiveSweepNs.Load()
if last != 0 && time.Duration(now-last) < archiveSweepEvery {
return
}
if !lastArchiveSweepNs.CompareAndSwap(last, now) {
return
}
n, err := db.AutoArchiveDoneOlderThan(archiveAfter)
if err != nil {
log.Printf("auto-archive failed: %v", err)
return
}
if n > 0 {
log.Printf("auto-archive moved %d done card(s) older than %s", n, archiveAfter)
}
}
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) {
maybeAutoArchive(db)
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,
})
}
}
// publishInvalidated emits a board.invalidated event so connected clients
// refetch /api/board. Best-effort: dropped events recover on next mutation
// or via the periodic safety reload kept in the SPA.
func publishInvalidated(hub *EventHub, cardID, columnID string) {
if hub == nil {
return
}
hub.PublishJSON("board.invalidated", cardID, "", map[string]string{
"card_id": cardID,
"column_id": columnID,
})
}
// POST /api/columns { name }
func handleCreateColumn(db *DB, hub *EventHub) 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
}
publishInvalidated(hub, "", c.ID)
infra.HTTPJSONResponse(w, http.StatusCreated, c)
}
}
// PATCH /api/columns/{id} { name?, position?, location?, width? }
func handleUpdateColumn(db *DB, hub *EventHub) 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"`
MaxTimeMinutes *int `json:"max_time_minutes"`
}
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, MaxTimeMinutes: body.MaxTimeMinutes}); err != nil {
serverError(w, err)
return
}
publishInvalidated(hub, "", id)
w.WriteHeader(http.StatusNoContent)
}
}
// DELETE /api/columns/{id}
func handleDeleteColumn(db *DB, hub *EventHub) 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
}
publishInvalidated(hub, "", id)
w.WriteHeader(http.StatusNoContent)
}
}
// POST /api/columns/reorder { ids: [...] }
func handleReorderColumns(db *DB, hub *EventHub) 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
}
publishInvalidated(hub, "", "")
w.WriteHeader(http.StatusNoContent)
}
}
// POST /api/cards { column_id, requester?, title, description? }
func handleCreateCard(db *DB, hub *EventHub) 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
}
// card.created drives outbound modules (Jira) to create the issue.
// Emitted after assignee/tags are applied so the synced issue carries
// them. board.invalidated stays for the SPA's refetch path.
hub.PublishJSON("card.created", c.ID, "", map[string]string{
"card_id": c.ID,
"column_id": body.ColumnID,
})
publishInvalidated(hub, c.ID, body.ColumnID)
infra.HTTPJSONResponse(w, http.StatusCreated, c)
}
}
// PATCH /api/cards/{id} { requester?, title?, description?, color? }
func handleUpdateCard(db *DB, hub *EventHub) 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
}
publishInvalidated(hub, id, "")
w.WriteHeader(http.StatusNoContent)
}
}
// PUT /api/cards/{id}/stickers { stickers: [{emoji,x,y}, ...] }
func handleUpdateCardStickers(db *DB, hub *EventHub) 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
}
publishInvalidated(hub, id, "")
w.WriteHeader(http.StatusNoContent)
}
}
// DELETE /api/cards/{id}
func handleDeleteCard(db *DB, hub *EventHub) 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
}
publishInvalidated(hub, id, "")
w.WriteHeader(http.StatusNoContent)
}
}
// POST /api/cards/{id}/move { column_id, ordered_ids }
func handleMoveCard(db *DB, hub *EventHub) 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
}
// Read the previous column BEFORE mutating so we can decide whether
// this is an actual column move (vs a same-column reorder). Outbound
// modules (Jira) only care about the former.
prevColumnID, _ := db.lookupCardColumnID(id)
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
}
// Distinct event when the card crossed columns so the Jira module
// runs transition() instead of plain update(). Reorder-only goes
// straight to board.invalidated (frontend refetch) without a Jira
// roundtrip.
if prevColumnID != "" && prevColumnID != body.ColumnID {
hub.PublishJSON("card.moved", id, "", map[string]string{
"card_id": id,
"from_column_id": prevColumnID,
"to_column_id": body.ColumnID,
})
}
publishInvalidated(hub, id, body.ColumnID)
w.WriteHeader(http.StatusNoContent)
}
}
// GET /api/cards/{id}/messages → [CardMessage, ...]
func handleListCardMessages(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
msgs, err := db.ListCardMessages(id)
if err != nil {
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusOK, msgs)
}
}
// POST /api/cards/{id}/messages { body }
//
// Parses @mentions, fans out notifications and publishes message.created via
// the hub so SSE/WS subscribers see the message immediately.
func handleCreateCardMessage(db *DB, hub *EventHub) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var body struct {
Body string `json:"body"`
}
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
badRequest(w, err.Error())
return
}
if strings.TrimSpace(body.Body) == "" {
badRequest(w, "body required")
return
}
actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
if actor == "" {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "session required"})
return
}
m, _, _, err := db.CreateCardMessageAndNotify(id, actor, body.Body, hub)
if err != nil {
if strings.Contains(err.Error(), "not found") {
notFound(w, err.Error())
return
}
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusCreated, m)
}
}
// DELETE /api/cards/{cid}/messages/{mid}
func handleDeleteCardMessage(db *DB, hub *EventHub) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cid := r.PathValue("id")
mid := r.PathValue("mid")
actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
if actor == "" {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "session required"})
return
}
if err := db.DeleteCardMessage(mid, actor); err != nil {
if strings.Contains(err.Error(), "not found") {
notFound(w, err.Error())
return
}
serverError(w, err)
return
}
if hub != nil {
hub.PublishJSON("message.deleted", cid, "", map[string]string{"id": mid})
}
w.WriteHeader(http.StatusNoContent)
}
}
// POST /api/cards/{id}/duplicate
func handleDuplicateCard(db *DB, hub *EventHub) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
c, err := db.DuplicateCard(id, actor)
if err != nil {
if strings.Contains(err.Error(), "not found") {
notFound(w, "card not found")
return
}
serverError(w, err)
return
}
publishInvalidated(hub, c.ID, c.ColumnID)
infra.HTTPJSONResponse(w, http.StatusCreated, c)
}
}
// 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, hub *EventHub) 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
}
publishInvalidated(hub, id, "")
w.WriteHeader(http.StatusNoContent)
}
}
// GET /api/reports/daily?date=YYYY-MM-DD&tz=Europe/Madrid
func handleDailyReport(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
date := r.URL.Query().Get("date")
if date == "" {
date = time.Now().UTC().Format("2006-01-02")
}
tz := r.URL.Query().Get("tz")
if tz == "" {
tz = "Europe/Madrid"
}
rep, err := db.DailyReportFor(date, tz)
if err != nil {
badRequest(w, err.Error())
return
}
infra.HTTPJSONResponse(w, http.StatusOK, rep)
}
}
// GET /api/reports/daily/summary?date=YYYY-MM-DD
func handleGetDailySummary(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
date := r.URL.Query().Get("date")
if date == "" {
date = time.Now().UTC().Format("2006-01-02")
}
s, err := db.GetDailySummary(date)
if err != nil {
serverError(w, err)
return
}
if s == nil {
infra.HTTPJSONResponse(w, http.StatusOK, map[string]any{"date": date, "summary": "", "exists": false})
return
}
infra.HTTPJSONResponse(w, http.StatusOK, map[string]any{
"date": s.Date, "summary": s.Summary, "prompt": s.Prompt,
"model": s.Model, "generated_at": s.GeneratedAt, "generated_by": s.GeneratedBy,
"exists": true,
})
}
}
// POST /api/reports/daily/summary?date=YYYY-MM-DD&tz=Europe/Madrid
// Regenera el resumen del dia y lo persiste.
func handleGenerateDailySummary(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
date := r.URL.Query().Get("date")
if date == "" {
date = time.Now().UTC().Format("2006-01-02")
}
tz := r.URL.Query().Get("tz")
if tz == "" {
tz = "Europe/Madrid"
}
actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
rec, err := db.GenerateDailySummary(r.Context(), date, tz, actor)
if err != nil {
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusOK, rec)
}
}
// GET /api/settings/{key}
func handleGetSetting(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
key := r.PathValue("key")
v, err := db.GetSetting(key)
if err != nil {
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusOK, map[string]any{"key": key, "value": v})
}
}
// PUT /api/settings/{key} body: {"value": "..."}
func handlePutSetting(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
key := r.PathValue("key")
var body struct {
Value string `json:"value"`
}
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
badRequest(w, err.Error())
return
}
actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
var actorPtr *string
if actor != "" {
actorPtr = &actor
}
if err := db.SetSetting(key, body.Value, actorPtr); err != nil {
serverError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// GET /api/archive
func handleListArchive(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cards, err := db.ListArchivedCards()
if err != nil {
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusOK, cards)
}
}
// POST /api/cards/{id}/archive
func handleArchiveCard(db *DB, hub *EventHub) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := db.ArchiveCard(id); err != nil {
serverError(w, err)
return
}
publishInvalidated(hub, id, "")
w.WriteHeader(http.StatusNoContent)
}
}
// POST /api/cards/{id}/unarchive
func handleUnarchiveCard(db *DB, hub *EventHub) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := db.UnarchiveCard(id); err != nil {
serverError(w, err)
return
}
publishInvalidated(hub, id, "")
w.WriteHeader(http.StatusNoContent)
}
}
// DELETE /api/cards/{id}/purge
func handlePurgeCard(db *DB, hub *EventHub) 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
}
publishInvalidated(hub, id, "")
w.WriteHeader(http.StatusNoContent)
}
}
func apiRoutes(db *DB, chatWorkdir string, logger *ChatLogger, internalToken string, flags *FeatureFlags, hub *EventHub, dispatcher *Dispatcher) []infra.Route {
return []infra.Route{
{Method: "GET", Path: "/api/flags", Handler: handleListFlags(flags)},
{Method: "GET", Path: "/api/version", Handler: handleVersion()},
{Method: "POST", Path: "/api/auth/register", Handler: handleRegister(db, flags)},
{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, hub)},
{Method: "POST", Path: "/api/columns/reorder", Handler: handleReorderColumns(db, hub)},
{Method: "PATCH", Path: "/api/columns/{id}", Handler: handleUpdateColumn(db, hub)},
{Method: "DELETE", Path: "/api/columns/{id}", Handler: handleDeleteColumn(db, hub)},
{Method: "POST", Path: "/api/cards", Handler: handleCreateCard(db, hub)},
{Method: "PATCH", Path: "/api/cards/{id}", Handler: handleUpdateCard(db, hub)},
{Method: "PUT", Path: "/api/cards/{id}/stickers", Handler: handleUpdateCardStickers(db, hub)},
{Method: "DELETE", Path: "/api/cards/{id}", Handler: handleDeleteCard(db, hub)},
{Method: "POST", Path: "/api/cards/{id}/move", Handler: handleMoveCard(db, hub)},
{Method: "POST", Path: "/api/cards/{id}/duplicate", Handler: handleDuplicateCard(db, hub)},
{Method: "GET", Path: "/api/cards/{id}/messages", Handler: handleListCardMessages(db)},
{Method: "POST", Path: "/api/cards/{id}/messages", Handler: handleCreateCardMessage(db, hub)},
{Method: "DELETE", Path: "/api/cards/{id}/messages/{mid}", Handler: handleDeleteCardMessage(db, hub)},
{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, hub)},
{Method: "GET", Path: "/api/reports/daily", Handler: handleDailyReport(db)},
{Method: "GET", Path: "/api/reports/daily/summary", Handler: handleGetDailySummary(db)},
{Method: "POST", Path: "/api/reports/daily/summary", Handler: handleGenerateDailySummary(db)},
{Method: "GET", Path: "/api/settings/{key}", Handler: handleGetSetting(db)},
{Method: "PUT", Path: "/api/settings/{key}", Handler: handlePutSetting(db)},
{Method: "GET", Path: "/api/archive", Handler: handleListArchive(db)},
{Method: "POST", Path: "/api/cards/{id}/archive", Handler: handleArchiveCard(db, hub)},
{Method: "POST", Path: "/api/cards/{id}/unarchive", Handler: handleUnarchiveCard(db, hub)},
{Method: "DELETE", Path: "/api/cards/{id}/purge", Handler: handlePurgeCard(db, hub)},
{Method: "POST", Path: "/api/chat", Handler: handleChat(db, chatWorkdir, logger)},
{Method: "GET", Path: "/api/chat/ws", Handler: handleChatWS(db, chatWorkdir, logger, internalToken)},
{Method: "POST", Path: "/api/tool/{name}", Handler: handleInternalTool(db, internalToken, 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)},
// Issue 0128: adjuntos de archivos.
{Method: "POST", Path: "/api/cards/{id}/files", Handler: handleUploadCardFile(db, chatWorkdir)},
{Method: "GET", Path: "/api/cards/{id}/files", Handler: handleListCardFiles(db)},
{Method: "GET", Path: "/api/files/{id}", Handler: handleServeFile(db)},
{Method: "DELETE", Path: "/api/files/{id}", Handler: handleDeleteCardFile(db)},
// Notifications + realtime (issue notifications-realtime).
{Method: "GET", Path: "/api/events", Handler: handleEventStream(hub)},
{Method: "GET", Path: "/api/cards/{id}/chat/ws", Handler: handleCardChatWS(db, hub)},
{Method: "GET", Path: "/api/notifications", Handler: handleListNotifications(db)},
{Method: "GET", Path: "/api/notifications/unread-count", Handler: handleUnreadCount(db)},
{Method: "POST", Path: "/api/notifications/{id}/read", Handler: handleMarkNotificationRead(db, hub)},
{Method: "POST", Path: "/api/notifications/read-all", Handler: handleMarkAllNotificationsRead(db, hub)},
// MCP per-user tokens.
{Method: "POST", Path: "/api/mcp-tokens", Handler: handleCreateMCPToken(db)},
{Method: "GET", Path: "/api/mcp-tokens", Handler: handleListMCPTokens(db)},
{Method: "DELETE", Path: "/api/mcp-tokens/{id}", Handler: handleRevokeMCPToken(db)},
// Modules: external integrations (Jira, ...).
{Method: "GET", Path: "/api/modules", Handler: handleListModules(db)},
{Method: "POST", Path: "/api/modules", Handler: handleCreateModule(db)},
{Method: "PATCH", Path: "/api/modules/{id}", Handler: handleUpdateModule(db)},
{Method: "DELETE", Path: "/api/modules/{id}", Handler: handleDeleteModule(db)},
{Method: "GET", Path: "/api/modules/{id}/logs", Handler: handleModuleLogs(db)},
{Method: "POST", Path: "/api/modules/{id}/test", Handler: handleTestModule(db, dispatcher)},
// Per-card Jira sync state (indicator + tooltip).
{Method: "GET", Path: "/api/cards/{id}/jira-sync", Handler: handleCardJiraSync(db, dispatcher)},
// Jira import: list issues not yet in kanban + bulk import.
{Method: "GET", Path: "/api/jira/issues", Handler: handleListJiraIssues(db)},
{Method: "POST", Path: "/api/jira/import", Handler: handleImportJiraIssues(db)},
// Jira column-sync check: detect drift between kanban col ↔ Jira status.
{Method: "GET", Path: "/api/jira/check-columns", Handler: handleCheckJiraColumns(db)},
{Method: "POST", Path: "/api/jira/reconcile-columns", Handler: handleReconcileJiraColumns(db)},
}
}
// GET /api/version → {"version": "<semver>"}
//
// Public, no auth. Skipped from session middleware via skip list updated in
// main.go to keep the SPA pre-login able to display the running build.
func handleVersion() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
infra.HTTPJSONResponse(w, http.StatusOK, map[string]string{"version": Version})
}
}
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)
}
}