diff --git a/internal/http/agents.go b/internal/http/agents.go index a5e07812..af70dd11 100644 --- a/internal/http/agents.go +++ b/internal/http/agents.go @@ -91,7 +91,7 @@ func (h *AgentsHandler) handleList(w http.ResponseWriter, r *http.Request) { userID := store.UserIDFromContext(r.Context()) if userID == "" { locale := store.LocaleFromContext(r.Context()) - writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgUserIDHeader)}) + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgUserIDHeader)) return } @@ -103,7 +103,9 @@ func (h *AgentsHandler) handleList(w http.ResponseWriter, r *http.Request) { agents, err = h.agents.ListAccessible(r.Context(), userID) } if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + slog.Error("agents.list", "error", err) + locale := store.LocaleFromContext(r.Context()) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "agents")) return } @@ -114,24 +116,24 @@ func (h *AgentsHandler) handleCreate(w http.ResponseWriter, r *http.Request) { userID := store.UserIDFromContext(r.Context()) locale := store.LocaleFromContext(r.Context()) if userID == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgUserIDHeader)}) + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgUserIDHeader)) return } var req store.AgentData if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, err.Error())}) + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, err.Error())) return } if !isValidSlug(req.AgentKey) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidSlug, "agent_key")}) + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidSlug, "agent_key")) return } // Check for duplicate agent_key before creating if existing, _ := h.agents.GetByKey(r.Context(), req.AgentKey); existing != nil { - writeJSON(w, http.StatusConflict, map[string]string{"error": i18n.T(locale, i18n.MsgAlreadyExists, "agent", req.AgentKey)}) + writeError(w, http.StatusConflict, protocol.ErrAlreadyExists, i18n.T(locale, i18n.MsgAlreadyExists, "agent", req.AgentKey)) return } @@ -182,16 +184,17 @@ func (h *AgentsHandler) handleCreate(w http.ResponseWriter, r *http.Request) { req.Provider, req.ParseChatGPTOAuthRouting(), ); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + slog.Error("agents.create.validate_routing", "error", err) + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, err.Error())) return } if err := h.agents.Create(r.Context(), &req); err != nil { if strings.Contains(err.Error(), "duplicate key") || strings.Contains(err.Error(), "23505") { - writeJSON(w, http.StatusConflict, map[string]string{"error": i18n.T(locale, i18n.MsgAlreadyExists, "agent", req.AgentKey)}) + writeError(w, http.StatusConflict, protocol.ErrAlreadyExists, i18n.T(locale, i18n.MsgAlreadyExists, "agent", req.AgentKey)) } else { slog.Error("agents.create", "agent_key", req.AgentKey, "error", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToCreate, "agent", "internal error")}) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToCreate, "agent", "internal error")) } return } @@ -221,12 +224,12 @@ func (h *AgentsHandler) handleGet(w http.ResponseWriter, r *http.Request) { // Try by agent_key ag, err2 := h.agents.GetByKey(r.Context(), r.PathValue("id")) if err2 != nil { - writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "agent", r.PathValue("id"))}) + writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "agent", r.PathValue("id"))) return } if userID != "" && !isOwner { if ok, _, _ := h.agents.CanAccess(r.Context(), ag.ID, userID); !ok { - writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgNoAccess, "agent")}) + writeError(w, http.StatusForbidden, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgNoAccess, "agent")) return } } @@ -236,13 +239,13 @@ func (h *AgentsHandler) handleGet(w http.ResponseWriter, r *http.Request) { ag, err := h.agents.GetByID(r.Context(), id) if err != nil { - writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "agent", id.String())}) + writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "agent", id.String())) return } if userID != "" && !isOwner { if ok, _, _ := h.agents.CanAccess(r.Context(), id, userID); !ok { - writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgNoAccess, "agent")}) + writeError(w, http.StatusForbidden, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgNoAccess, "agent")) return } } @@ -255,24 +258,24 @@ func (h *AgentsHandler) handleUpdate(w http.ResponseWriter, r *http.Request) { locale := store.LocaleFromContext(r.Context()) id, err := uuid.Parse(r.PathValue("id")) if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "agent")}) + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "agent")) return } // Only owner can update ag, err := h.agents.GetByID(r.Context(), id) if err != nil { - writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "agent", id.String())}) + writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "agent", id.String())) return } if userID != "" && ag.OwnerID != userID && !h.isOwnerUser(userID) { - writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgOwnerOnly, "update agent")}) + writeError(w, http.StatusForbidden, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgOwnerOnly, "update agent")) return } var updates map[string]any if err := json.NewDecoder(r.Body).Decode(&updates); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, err.Error())}) + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, err.Error())) return } @@ -290,7 +293,7 @@ func (h *AgentsHandler) handleUpdate(w http.ResponseWriter, r *http.Request) { if otherConfig, ok := allowed["other_config"]; ok { rawOtherConfig, err := marshalJSONRaw(otherConfig) if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)}) + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)) return } validationAgent.OtherConfig = rawOtherConfig @@ -302,12 +305,14 @@ func (h *AgentsHandler) handleUpdate(w http.ResponseWriter, r *http.Request) { validationAgent.Provider, validationAgent.ParseChatGPTOAuthRouting(), ); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + slog.Error("agents.update.validate_routing", "id", id, "error", err) + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, err.Error())) return } if err := h.agents.Update(r.Context(), id, allowed); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + slog.Error("agents.update", "id", id, "error", err) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToUpdate, "agent", "internal error")) return } @@ -337,23 +342,24 @@ func (h *AgentsHandler) handleDelete(w http.ResponseWriter, r *http.Request) { locale := store.LocaleFromContext(r.Context()) id, err := uuid.Parse(r.PathValue("id")) if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "agent")}) + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "agent")) return } // Only owner can delete ag, err := h.agents.GetByID(r.Context(), id) if err != nil { - writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "agent", id.String())}) + writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "agent", id.String())) return } if userID != "" && ag.OwnerID != userID && !h.isOwnerUser(userID) { - writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgOwnerOnly, "delete agent")}) + writeError(w, http.StatusForbidden, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgOwnerOnly, "delete agent")) return } if err := h.agents.Delete(r.Context(), id); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + slog.Error("agents.delete", "id", id, "error", err) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToDelete, "agent", "internal error")) return } diff --git a/internal/http/agents_sharing.go b/internal/http/agents_sharing.go index 9cdbaeaf..b4dfa668 100644 --- a/internal/http/agents_sharing.go +++ b/internal/http/agents_sharing.go @@ -233,8 +233,4 @@ func extractDescription(raw json.RawMessage) string { return desc } -func writeJSON(w http.ResponseWriter, status int, data any) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - json.NewEncoder(w).Encode(data) -} +// writeJSON moved to response_helpers.go diff --git a/internal/http/api_keys.go b/internal/http/api_keys.go index 54e474e6..6df8a160 100644 --- a/internal/http/api_keys.go +++ b/internal/http/api_keys.go @@ -45,7 +45,7 @@ func (h *APIKeysHandler) handleList(w http.ResponseWriter, r *http.Request) { keys, err := h.apiKeys.List(r.Context(), "") if err != nil { slog.Error("api_keys.list failed", "error", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "API keys")}) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "API keys")) return } if keys == nil { @@ -64,27 +64,27 @@ func (h *APIKeysHandler) handleCreate(w http.ResponseWriter, r *http.Request) { TenantID string `json:"tenant_id"` // optional UUID; cross-tenant callers may specify or omit (NULL = system key) } if err := json.NewDecoder(r.Body).Decode(&input); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)}) + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)) return } if input.Name == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "name")}) + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "name")) return } if len(input.Name) > 100 { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, "name must be 100 characters or less")}) + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, "name must be 100 characters or less")) return } if len(input.Scopes) == 0 { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "scopes")}) + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "scopes")) return } // Validate scopes for _, s := range input.Scopes { if !permissions.ValidScope(s) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, "invalid scope: "+s)}) + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, "invalid scope: "+s)) return } } @@ -92,7 +92,7 @@ func (h *APIKeysHandler) handleCreate(w http.ResponseWriter, r *http.Request) { raw, hash, prefix, err := crypto.GenerateAPIKey() if err != nil { slog.Error("api_keys.generate failed", "error", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "key generation")}) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "key generation")) return } @@ -102,7 +102,7 @@ func (h *APIKeysHandler) handleCreate(w http.ResponseWriter, r *http.Request) { if input.TenantID != "" { tid, err := uuid.Parse(input.TenantID) if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "tenant_id")}) + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "tenant_id")) return } tenantID = tid @@ -132,7 +132,7 @@ func (h *APIKeysHandler) handleCreate(w http.ResponseWriter, r *http.Request) { if err := h.apiKeys.Create(r.Context(), key); err != nil { slog.Error("api_keys.create failed", "error", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToCreate, "API key", "internal error")}) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToCreate, "API key", "internal error")) return } @@ -155,14 +155,14 @@ func (h *APIKeysHandler) handleRevoke(w http.ResponseWriter, r *http.Request) { idStr := r.PathValue("id") id, err := uuid.Parse(idStr) if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "API key")}) + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "API key")) return } // HTTP revoke is admin-only (adminAuth middleware), so no owner filter needed. if err := h.apiKeys.Revoke(r.Context(), id, ""); err != nil { slog.Error("api_keys.revoke failed", "error", err, "id", idStr) - writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "API key", idStr)}) + writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "API key", idStr)) return } diff --git a/internal/http/channel_instances.go b/internal/http/channel_instances.go index 17fe0f35..39fcfb0f 100644 --- a/internal/http/channel_instances.go +++ b/internal/http/channel_instances.go @@ -97,7 +97,7 @@ func (h *ChannelInstancesHandler) handleList(w http.ResponseWriter, r *http.Requ if err != nil { slog.Error("channel_instances.list", "error", err) locale := store.LocaleFromContext(r.Context()) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "instances")}) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "instances")) return } @@ -128,23 +128,23 @@ func (h *ChannelInstancesHandler) handleCreate(w http.ResponseWriter, r *http.Re Enabled *bool `json:"enabled"` } if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&body); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)}) + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)) return } if body.Name == "" || body.ChannelType == "" || body.AgentID == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "name, channel_type, and agent_id")}) + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "name, channel_type, and agent_id")) return } if !isValidChannelType(body.ChannelType) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidChannelType)}) + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidChannelType)) return } agentID, err := uuid.Parse(body.AgentID) if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "agent")}) + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "agent")) return } @@ -168,7 +168,7 @@ func (h *ChannelInstancesHandler) handleCreate(w http.ResponseWriter, r *http.Re if err := h.store.Create(r.Context(), inst); err != nil { slog.Error("channel_instances.create", "error", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToCreate, "channel instance", "internal error")}) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToCreate, "channel instance", "internal error")) return } @@ -181,13 +181,13 @@ func (h *ChannelInstancesHandler) handleGet(w http.ResponseWriter, r *http.Reque locale := store.LocaleFromContext(r.Context()) id, err := uuid.Parse(r.PathValue("id")) if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "instance")}) + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "instance")) return } inst, err := h.store.Get(r.Context(), id) if err != nil { - writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgInstanceNotFound)}) + writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgInstanceNotFound)) return } @@ -198,13 +198,13 @@ func (h *ChannelInstancesHandler) handleUpdate(w http.ResponseWriter, r *http.Re locale := store.LocaleFromContext(r.Context()) id, err := uuid.Parse(r.PathValue("id")) if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "instance")}) + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "instance")) return } var updates map[string]any if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&updates); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)}) + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)) return } @@ -213,7 +213,7 @@ func (h *ChannelInstancesHandler) handleUpdate(w http.ResponseWriter, r *http.Re if err := h.store.Update(r.Context(), id, updates); err != nil { slog.Error("channel_instances.update", "error", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToUpdate, "channel instance", "internal error")}) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToUpdate, "channel instance", "internal error")) return } @@ -226,24 +226,24 @@ func (h *ChannelInstancesHandler) handleDelete(w http.ResponseWriter, r *http.Re locale := store.LocaleFromContext(r.Context()) id, err := uuid.Parse(r.PathValue("id")) if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "instance")}) + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "instance")) return } // Look up instance to check if it's a default (seeded) instance. inst, err := h.store.Get(r.Context(), id) if err != nil { - writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgInstanceNotFound)}) + writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgInstanceNotFound)) return } if store.IsDefaultChannelInstance(inst.Name) { - writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgCannotDeleteDefaultInst)}) + writeError(w, http.StatusForbidden, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgCannotDeleteDefaultInst)) return } if err := h.store.Delete(r.Context(), id); err != nil { slog.Error("channel_instances.delete", "error", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToDelete, "channel instance", "internal error")}) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToDelete, "channel instance", "internal error")) return } @@ -294,12 +294,12 @@ func (h *ChannelInstancesHandler) resolveAgentID(w http.ResponseWriter, r *http. locale := store.LocaleFromContext(r.Context()) instID, err := uuid.Parse(r.PathValue("id")) if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "instance")}) + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "instance")) return uuid.Nil, false } inst, err := h.store.Get(r.Context(), instID) if err != nil { - writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgInstanceNotFound)}) + writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgInstanceNotFound)) return uuid.Nil, false } return inst.AgentID, true @@ -314,7 +314,7 @@ func (h *ChannelInstancesHandler) handleWriterGroups(w http.ResponseWriter, r *h if err != nil { slog.Error("channel_instances.writer_groups", "error", err) locale := store.LocaleFromContext(r.Context()) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "writer groups")}) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "writer groups")) return } // Group by scope @@ -343,13 +343,13 @@ func (h *ChannelInstancesHandler) handleListWriters(w http.ResponseWriter, r *ht locale := store.LocaleFromContext(r.Context()) groupID := r.URL.Query().Get("group_id") if groupID == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "group_id")}) + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "group_id")) return } perms, err := h.configPermStore.List(r.Context(), agentID, "file_writer", groupID) if err != nil { slog.Error("channel_instances.list_writers", "error", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "writers")}) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "writers")) return } type writerData struct { @@ -393,11 +393,11 @@ func (h *ChannelInstancesHandler) handleAddWriter(w http.ResponseWriter, r *http Username string `json:"username"` } if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&body); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)}) + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)) return } if body.GroupID == "" || body.UserID == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "group_id and user_id")}) + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "group_id and user_id")) return } meta, _ := json.Marshal(map[string]string{"displayName": body.DisplayName, "username": body.Username}) @@ -410,7 +410,7 @@ func (h *ChannelInstancesHandler) handleAddWriter(w http.ResponseWriter, r *http Metadata: meta, }); err != nil { slog.Error("channel_instances.add_writer", "error", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToCreate, "writer", err.Error())}) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToCreate, "writer", "internal error")) return } writeJSON(w, http.StatusCreated, map[string]string{"status": "added"}) @@ -425,7 +425,7 @@ func (h *ChannelInstancesHandler) handleRemoveWriter(w http.ResponseWriter, r *h userID := r.PathValue("userId") groupID := r.URL.Query().Get("group_id") if groupID == "" || userID == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "group_id and userId")}) + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "group_id and userId")) return } // Prevent removing the last writer (same guard as Telegram /removewriter) @@ -437,12 +437,12 @@ func (h *ChannelInstancesHandler) handleRemoveWriter(w http.ResponseWriter, r *h } } if allowCount <= 1 { - writeJSON(w, http.StatusConflict, map[string]string{"error": "cannot remove the last file writer"}) + writeError(w, http.StatusConflict, protocol.ErrFailedPrecondition, "cannot remove the last file writer") return } if err := h.configPermStore.Revoke(r.Context(), agentID, groupID, "file_writer", userID); err != nil { slog.Error("channel_instances.remove_writer", "error", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToDelete, "writer", err.Error())}) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToDelete, "writer", "internal error")) return } writeJSON(w, http.StatusOK, map[string]string{"status": "removed"}) @@ -480,7 +480,7 @@ func (h *ChannelInstancesHandler) handleListContacts(w http.ResponseWriter, r *h if err != nil { slog.Error("contacts.list", "error", err) locale := store.LocaleFromContext(r.Context()) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "contacts")}) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "contacts")) return } if contacts == nil { @@ -516,7 +516,7 @@ func (h *ChannelInstancesHandler) handleResolveContacts(w http.ResponseWriter, r if err != nil { slog.Error("contacts.resolve", "error", err) locale := store.LocaleFromContext(r.Context()) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "contacts")}) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "contacts")) return } diff --git a/internal/http/response_helpers.go b/internal/http/response_helpers.go new file mode 100644 index 00000000..948846bf --- /dev/null +++ b/internal/http/response_helpers.go @@ -0,0 +1,32 @@ +package http + +import ( + "encoding/json" + "net/http" +) + +// ErrorResponse is the standard HTTP error envelope. +// Aligns with WS protocol.ErrorShape for frontend consistency. +type ErrorResponse struct { + Code string `json:"code"` + Message string `json:"message"` +} + +// writeError writes a structured error response with code + i18n message. +// code should be a protocol.Err* constant (e.g., protocol.ErrInvalidRequest). +// msg should already be i18n-translated via i18n.T(). +func writeError(w http.ResponseWriter, status int, code, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(map[string]ErrorResponse{ + "error": {Code: code, Message: msg}, + }) +} + +// writeJSON writes a JSON response with the given status code. +// Used for success responses and legacy error responses during migration. +func writeJSON(w http.ResponseWriter, status int, data any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(data) +} diff --git a/ui/desktop/frontend/src/components/settings/channels/PairedDevicesSection.tsx b/ui/desktop/frontend/src/components/settings/channels/PairedDevicesSection.tsx index ddcd8113..2f138f07 100644 --- a/ui/desktop/frontend/src/components/settings/channels/PairedDevicesSection.tsx +++ b/ui/desktop/frontend/src/components/settings/channels/PairedDevicesSection.tsx @@ -2,6 +2,7 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import { usePairedDevices } from '../../../hooks/use-paired-devices' import { ConfirmDialog } from '../../common/ConfirmDialog' +import { RefreshButton } from '../../common/RefreshButton' import type { PendingPairing, PairedDevice } from '../../../types/channel' function formatRelativeTime(ms: number): string { @@ -30,11 +31,7 @@ export function PairedDevicesSection() { {/* Header */}

{t('pairing.title')}

- +
{isEmpty && !loading ? ( diff --git a/ui/desktop/frontend/src/hooks/use-channel-crud.ts b/ui/desktop/frontend/src/hooks/use-channel-crud.ts index a2c6f393..d955af79 100644 --- a/ui/desktop/frontend/src/hooks/use-channel-crud.ts +++ b/ui/desktop/frontend/src/hooks/use-channel-crud.ts @@ -22,7 +22,17 @@ export function useChannelCrud() { const createInstance = useCallback(async (input: ChannelInstanceInput) => { try { - const res = await getApiClient().post<{ id: string }>('/v1/channels/instances', input) + // Map camelCase input to snake_case for backend HTTP API + const payload = { + name: input.name, + display_name: input.displayName, + channel_type: input.channelType, + agent_id: input.agentId, + credentials: input.credentials, + config: input.config, + enabled: input.enabled, + } + const res = await getApiClient().post<{ id: string }>('/v1/channels/instances', payload) await fetchInstances() toast.success('Channel created') return res