9b0b6e516c
Bug: handleMoveCard solo emitia board.invalidated. Dispatcher mapeaba a update() (PUT summary/description/labels) NUNCA a transition(), asi que mover una card en kanban no transicionaba su Jira issue de columna. Solo los labels reflejaban el cambio. Fix backend (handlers.go): - handleMoveCard ahora lee column_id antes del MoveCard. Si la card crusa columnas (prev != new) publica 'card.moved' antes de 'board.invalidated'. El dispatcher reconoce 'card.moved' y ejecuta transition() -> Jira status cambia + labels sincronizan. - Reorder dentro de la misma columna sigue como antes: solo board.invalidated para refetch del cliente sin tocar Jira. - nuevo helper db.lookupCardColumnID(cardID). UX frontend (JiraSyncIndicator): - Polling adaptativo: 5s steady, 1s mientras inflight=true. El usuario VE el yellow durante el sync. - Listener de window CustomEvent 'kanban-card-moved' (cardId match) que fuerza un refetch inmediato (~150ms) tras drop. App.tsx dispara el evento tras api.moveCard resolve. Yellow visible casi instantaneo en lugar de esperar al proximo tick steady.
744 lines
24 KiB
Go
744 lines
24 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
|
|
}
|
|
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)},
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|