feat(dashboard-apps): align chatwoot payloads
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user