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:
viettranx committed 2026-03-27 20:15:26 +07:00
1 parent 6ea9b4d762
commit 0a8ca13d08
7 files changed
+115 -74

No files matched your search

+30 -24
View File
@@ -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
}
+1 -5
View File
@@ -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
View File
@@ -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
}
+28 -28
View File
@@ -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
}
+32
View File
@@ -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