feat(dashboard-apps): align chatwoot payloads

This commit is contained in:
2026-06-06 13:03:16 +08:00
parent 11e5537f9b
commit 75f0e809a5
9 changed files with 312 additions and 71 deletions
+123 -28
View File
@@ -1,13 +1,18 @@
package v1
import (
"encoding/json"
"errors"
"net/http"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/service"
applogger "github.com/gochat/gochat/pkg/logger"
"github.com/gochat/gochat/pkg/response"
pkgvalidator "github.com/gochat/gochat/pkg/validator"
)
// DashboardAppHandler handles DashboardApp CRUD endpoints.
@@ -21,6 +26,13 @@ type DashboardAppHandler struct {
svc *service.DashboardAppService
}
type dashboardAppPayload struct {
ID uint `json:"id"`
Title string `json:"title"`
Content json.RawMessage `json:"content"`
CreatedAt time.Time `json:"created_at"`
}
func NewDashboardAppHandler(svc *service.DashboardAppService) *DashboardAppHandler {
return &DashboardAppHandler{svc: svc}
}
@@ -37,26 +49,24 @@ func (h *DashboardAppHandler) Create(c *gin.Context) {
userID := getUserID(c)
userIDPtr := &userID
var wrapper service.DashboardAppCreateWrapper
if err := c.ShouldBindJSON(&wrapper); err != nil {
req, err := bindDashboardAppCreate(c)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
return
}
req := wrapper.DashboardApp
if err := pkgvalidator.ValidateStruct(&req); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
if req.Title == "" {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, "title is required")
return
}
app, err := h.svc.Create(c.Request.Context(), accountID, userIDPtr, &req)
app, err := h.svc.Create(c.Request.Context(), accountID, userIDPtr, req)
if err != nil {
applogger.L().Errorf("Create dashboard app: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to create dashboard app")
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrValidation, err.Error())
return
}
response.Created(c, app)
c.JSON(http.StatusOK, serializeDashboardApp(app))
}
// Get retrieves a dashboard app by ID.
@@ -68,14 +78,20 @@ func (h *DashboardAppHandler) Get(c *gin.Context) {
return
}
app, err := h.svc.GetByID(c.Request.Context(), id)
accountID := getAccountID(c)
if accountID == 0 {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
}
app, err := h.svc.GetByAccountAndID(c.Request.Context(), accountID, id)
if err != nil {
applogger.L().Errorf("Get dashboard app: %v", err)
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "dashboard app not found")
return
}
response.OK(c, app)
c.JSON(http.StatusOK, serializeDashboardApp(app))
}
// Update modifies an existing dashboard app.
@@ -88,21 +104,26 @@ func (h *DashboardAppHandler) Update(c *gin.Context) {
return
}
var wrapper service.DashboardAppUpdateWrapper
if err := c.ShouldBindJSON(&wrapper); err != nil {
accountID := getAccountID(c)
if accountID == 0 {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
}
req, err := bindDashboardAppUpdate(c)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
return
}
req := wrapper.DashboardApp
app, err := h.svc.Update(c.Request.Context(), id, &req)
app, err := h.svc.UpdateByAccountAndID(c.Request.Context(), accountID, id, req)
if err != nil {
applogger.L().Errorf("Update dashboard app: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to update dashboard app")
handleDashboardAppMutationError(c, err)
return
}
response.OK(c, app)
c.JSON(http.StatusOK, serializeDashboardApp(app))
}
// Delete removes a dashboard app.
@@ -115,8 +136,18 @@ func (h *DashboardAppHandler) Delete(c *gin.Context) {
return
}
if err := h.svc.Delete(c.Request.Context(), id); err != nil {
accountID := getAccountID(c)
if accountID == 0 {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
}
if err := h.svc.DeleteByAccountAndID(c.Request.Context(), accountID, id); err != nil {
applogger.L().Errorf("Delete dashboard app: %v", err)
if errors.Is(err, gorm.ErrRecordNotFound) {
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "dashboard app not found")
return
}
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to delete dashboard app")
return
}
@@ -133,21 +164,26 @@ func (h *DashboardAppHandler) Patch(c *gin.Context) {
return
}
var wrapper service.DashboardAppUpdateWrapper
if err := c.ShouldBindJSON(&wrapper); err != nil {
accountID := getAccountID(c)
if accountID == 0 {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
}
req, err := bindDashboardAppUpdate(c)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
return
}
req := wrapper.DashboardApp
app, err := h.svc.Update(c.Request.Context(), id, &req)
app, err := h.svc.UpdateByAccountAndID(c.Request.Context(), accountID, id, req)
if err != nil {
applogger.L().Errorf("Patch dashboard app: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to update dashboard app")
handleDashboardAppMutationError(c, err)
return
}
response.OK(c, app)
c.JSON(http.StatusOK, serializeDashboardApp(app))
}
// List returns all dashboard apps for an account.
@@ -167,8 +203,67 @@ func (h *DashboardAppHandler) List(c *gin.Context) {
return
}
// Chatwoot returns a pure JSON array (no meta/pagination wrapper)
c.JSON(http.StatusOK, apps)
payload := make([]dashboardAppPayload, 0, len(apps))
for i := range apps {
payload = append(payload, serializeDashboardApp(&apps[i]))
}
c.JSON(http.StatusOK, payload)
}
func bindDashboardAppCreate(c *gin.Context) (*service.CreateDashboardAppRequest, error) {
var raw map[string]json.RawMessage
if err := c.ShouldBindJSON(&raw); err != nil {
return nil, err
}
if nested, ok := raw["dashboard_app"]; ok {
var req service.CreateDashboardAppRequest
if err := json.Unmarshal(nested, &req); err != nil {
return nil, err
}
return &req, nil
}
body, _ := json.Marshal(raw)
var req service.CreateDashboardAppRequest
if err := json.Unmarshal(body, &req); err != nil {
return nil, err
}
return &req, nil
}
func bindDashboardAppUpdate(c *gin.Context) (*service.UpdateDashboardAppRequest, error) {
var raw map[string]json.RawMessage
if err := c.ShouldBindJSON(&raw); err != nil {
return nil, err
}
if nested, ok := raw["dashboard_app"]; ok {
var req service.UpdateDashboardAppRequest
if err := json.Unmarshal(nested, &req); err != nil {
return nil, err
}
return &req, nil
}
body, _ := json.Marshal(raw)
var req service.UpdateDashboardAppRequest
if err := json.Unmarshal(body, &req); err != nil {
return nil, err
}
return &req, nil
}
func serializeDashboardApp(app *model.DashboardApp) dashboardAppPayload {
content := app.Content
if len(content) == 0 {
content = json.RawMessage(`[]`)
}
return dashboardAppPayload{ID: app.ID, Title: app.Title, Content: content, CreatedAt: app.CreatedAt}
}
func handleDashboardAppMutationError(c *gin.Context, err error) {
if errors.Is(err, gorm.ErrRecordNotFound) {
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "dashboard app not found")
return
}
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrValidation, err.Error())
}
// ========== GoChat Extension Endpoints (not in Chatwoot) ==========
@@ -297,4 +392,4 @@ func (h *DashboardAppHandler) UpdateWidget(c *gin.Context) {
}
response.OK(c, widgets)
}
}
@@ -117,14 +117,22 @@ func (s *DashboardAppHandlerTestSuite) TestCreate_Success() {
c.Next()
}, s.handler.Create)
// Chatwoot requires nested {dashboard_app: {title: "...", content: [...]}}
body := `{"dashboard_app": {"title": "Test Dashboard App", "content": [{"type": "frame", "url": "https://example.com/widget"}]}}`
// The reused Chatwoot frontend sends a raw payload; Rails wraps it into
// dashboard_app server-side, so GoChat accepts both shapes.
body := `{"title": "Test Dashboard App", "content": [{"type": "frame", "url": "https://example.com/widget"}]}`
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/dashboard_apps", s.account.ID), bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusCreated, w.Code)
assert.Equal(s.T(), http.StatusOK, w.Code)
var payload map[string]any
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload))
assert.NotContains(s.T(), payload, "success")
assert.NotContains(s.T(), payload, "data")
assert.Equal(s.T(), "Test Dashboard App", payload["title"])
assert.NotContains(s.T(), payload, "account_id")
assert.NotContains(s.T(), payload, "updated_at")
}
func (s *DashboardAppHandlerTestSuite) TestGet_Success() {
@@ -144,6 +152,42 @@ func (s *DashboardAppHandlerTestSuite) TestGet_Success() {
s.T().Logf("Get response: status=%d, body=%s", w.Code, w.Body.String())
assert.Equal(s.T(), http.StatusOK, w.Code)
var payload map[string]any
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload))
assert.NotContains(s.T(), payload, "success")
assert.NotContains(s.T(), payload, "data")
assert.Equal(s.T(), "Seed App", payload["title"])
assert.Contains(s.T(), payload, "created_at")
assert.NotContains(s.T(), payload, "account_id")
}
func (s *DashboardAppHandlerTestSuite) TestPatch_RawPayloadAndAccountScope() {
r := gin.New()
r.PATCH("/api/v1/accounts/:account_id/dashboard_apps/:dashboard_app_id", s.handler.Patch)
seedApp, err := s.handler.svc.Create(context.Background(), s.account.ID, &s.user.ID, &service.CreateDashboardAppRequest{
Title: "Before",
Content: json.RawMessage(`[{"type":"frame","url":"https://example.com/before"}]`),
})
s.Require().NoError(err)
body := `{"title":"After","content":[{"type":"frame","url":"https://example.com/after"}]}`
w := httptest.NewRecorder()
req, _ := http.NewRequest("PATCH", fmt.Sprintf("/api/v1/accounts/%d/dashboard_apps/%d", s.account.ID, seedApp.ID), bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
var payload map[string]any
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload))
assert.Equal(s.T(), "After", payload["title"])
assert.NotContains(s.T(), payload, "success")
w = httptest.NewRecorder()
req, _ = http.NewRequest("PATCH", fmt.Sprintf("/api/v1/accounts/%d/dashboard_apps/%d", s.account.ID+100, seedApp.ID), bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusNotFound, w.Code)
}
func (s *DashboardAppHandlerTestSuite) TestDelete_Success() {
@@ -162,4 +206,4 @@ func (s *DashboardAppHandlerTestSuite) TestDelete_Success() {
// Chatwoot: head :no_content → 204
assert.Equal(s.T(), http.StatusNoContent, w.Code)
}
}