c28ae7d3c0
- app.md - backend/handlers.go - backend/main.go - frontend/src/App.tsx - frontend/src/api.ts - frontend/vite.config.ts - backend/mcp_http.go - backend/mcp_tokens.go - backend/mcp_tokens_handlers.go - backend/migrations/016_mcp_tokens.sql - ... Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
716 lines
23 KiB
Go
716 lines
23 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
|
|
}
|
|
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
|
|
}
|
|
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)},
|
|
{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)},
|
|
{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)},
|
|
{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)},
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|