mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-18 08:14:39 +00:00
refactor(http): standardize error responses with writeError helper
- Create response_helpers.go with writeError(w, status, code, msg) for structured errors - Move writeJSON to response_helpers.go for co-location - Migrate agents.go, channel_instances.go, api_keys.go to writeError with protocol error codes - Replace raw err.Error() with i18n messages + slog.Error for debugging - Fix channel creation: map camelCase input to snake_case for backend HTTP API - Fix PairedDevicesSection reload button: use RefreshButton component
This commit is contained in:
1 parent
6ea9b4d762
commit
0a8ca13d08
7 files changed
+115
-74
No files matched your search
+30
-24
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
+11
-11
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-xs font-semibold text-text-secondary">{t('pairing.title')}</h3>
|
||||
<button onClick={refresh} disabled={loading} className="p-1 text-text-muted hover:text-text-primary transition-colors cursor-pointer">
|
||||
<svg className={`h-3.5 w-3.5 ${loading ? 'animate-spin' : ''}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||
</svg>
|
||||
</button>
|
||||
<RefreshButton onRefresh={refresh} />
|
||||
</div>
|
||||
|
||||
{isEmpty && !loading ? (
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user