package main import ( "net/http" "strings" "fn-registry/functions/infra" ) const maxBodyBytes = 1 << 20 // 1 MiB 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) { 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, }) } } // POST /api/columns { name } func handleCreateColumn(db *DB) 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 } infra.HTTPJSONResponse(w, http.StatusCreated, c) } } // PATCH /api/columns/{id} { name?, position?, location?, width? } func handleUpdateColumn(db *DB) 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"` } 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}); err != nil { serverError(w, err) return } w.WriteHeader(http.StatusNoContent) } } // DELETE /api/columns/{id} func handleDeleteColumn(db *DB) 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 } w.WriteHeader(http.StatusNoContent) } } // POST /api/columns/reorder { ids: [...] } func handleReorderColumns(db *DB) 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 } w.WriteHeader(http.StatusNoContent) } } // POST /api/cards { column_id, requester?, title, description? } func handleCreateCard(db *DB) 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 } infra.HTTPJSONResponse(w, http.StatusCreated, c) } } // PATCH /api/cards/{id} { requester?, title?, description?, color? } func handleUpdateCard(db *DB) 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 } w.WriteHeader(http.StatusNoContent) } } // PUT /api/cards/{id}/stickers { stickers: [{emoji,x,y}, ...] } func handleUpdateCardStickers(db *DB) 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 } w.WriteHeader(http.StatusNoContent) } } // DELETE /api/cards/{id} func handleDeleteCard(db *DB) 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 } w.WriteHeader(http.StatusNoContent) } } // POST /api/cards/{id}/move { column_id, ordered_ids } func handleMoveCard(db *DB) 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 } 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 } func handleCreateCardMessage(db *DB) 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.CreateCardMessage(id, actor, body.Body) 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) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { 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 } w.WriteHeader(http.StatusNoContent) } } // POST /api/cards/{id}/duplicate func handleDuplicateCard(db *DB) 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 } 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) 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 } w.WriteHeader(http.StatusNoContent) } } // DELETE /api/cards/{id}/purge func handlePurgeCard(db *DB) 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 } w.WriteHeader(http.StatusNoContent) } } func apiRoutes(db *DB, chatWorkdir string, logger *ChatLogger, internalToken string, flags *FeatureFlags) []infra.Route { routes := []infra.Route{ {Method: "GET", Path: "/api/flags", Handler: handleListFlags(flags)}, {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)}, {Method: "POST", Path: "/api/columns/reorder", Handler: handleReorderColumns(db)}, {Method: "PATCH", Path: "/api/columns/{id}", Handler: handleUpdateColumn(db)}, {Method: "DELETE", Path: "/api/columns/{id}", Handler: handleDeleteColumn(db)}, {Method: "POST", Path: "/api/cards", Handler: handleCreateCard(db)}, {Method: "PATCH", Path: "/api/cards/{id}", Handler: handleUpdateCard(db)}, {Method: "PUT", Path: "/api/cards/{id}/stickers", Handler: handleUpdateCardStickers(db)}, {Method: "DELETE", Path: "/api/cards/{id}", Handler: handleDeleteCard(db)}, {Method: "POST", Path: "/api/cards/{id}/move", Handler: handleMoveCard(db)}, {Method: "POST", Path: "/api/cards/{id}/duplicate", Handler: handleDuplicateCard(db)}, {Method: "GET", Path: "/api/cards/{id}/messages", Handler: handleListCardMessages(db)}, {Method: "POST", Path: "/api/cards/{id}/messages", Handler: handleCreateCardMessage(db)}, {Method: "DELETE", Path: "/api/cards/{id}/messages/{mid}", Handler: handleDeleteCardMessage(db)}, {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)}, {Method: "DELETE", Path: "/api/cards/{id}/purge", Handler: handlePurgeCard(db)}, {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)}, } routes = append(routes, boardRoutes()...) return routes } 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) } }