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" ) // DashboardAppHandler handles DashboardApp CRUD endpoints. // Reference: Chatwoot app/controllers/api/v1/accounts/dashboard_apps_controller.rb // Chatwoot routes: resources :dashboard_apps, only: [:index, :show, :create, :update, :destroy] // Chatwoot request: params.require(:dashboard_app).permit(:title, content: [:url, :type]) // Chatwoot response: Jbuilder partial — {id, title, content, created_at} // Chatwoot destroy: head :no_content (204) // GoChat extensions: search, widgets CRUD (not in Chatwoot) 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} } // Create creates a new dashboard app. // POST /api/v1/accounts/:account_id/dashboard_apps // Chatwoot: Current.account.dashboard_apps.create!(permitted_payload.merge(user_id: Current.user.id)) func (h *DashboardAppHandler) Create(c *gin.Context) { accountID := getAccountID(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } userID := getUserID(c) userIDPtr := &userID req, err := bindDashboardAppCreate(c) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } 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) if err != nil { applogger.L().Errorf("Create dashboard app: %v", err) response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrValidation, err.Error()) return } c.JSON(http.StatusOK, serializeDashboardApp(app)) } // Get retrieves a dashboard app by ID. // GET /api/v1/accounts/:account_id/dashboard_apps/:id func (h *DashboardAppHandler) Get(c *gin.Context) { id, err := parseDashboardAppIDParam(c) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid dashboard_app_id") return } 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 } c.JSON(http.StatusOK, serializeDashboardApp(app)) } // Update modifies an existing dashboard app. // PUT /api/v1/accounts/:account_id/dashboard_apps/:id // Chatwoot: @dashboard_app.update!(permitted_payload) func (h *DashboardAppHandler) Update(c *gin.Context) { id, err := parseDashboardAppIDParam(c) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid dashboard_app_id") return } 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 } app, err := h.svc.UpdateByAccountAndID(c.Request.Context(), accountID, id, req) if err != nil { applogger.L().Errorf("Update dashboard app: %v", err) handleDashboardAppMutationError(c, err) return } c.JSON(http.StatusOK, serializeDashboardApp(app)) } // Delete removes a dashboard app. // DELETE /api/v1/accounts/:account_id/dashboard_apps/:id // Chatwoot: @dashboard_app.destroy!; head :no_content (204) func (h *DashboardAppHandler) Delete(c *gin.Context) { id, err := parseDashboardAppIDParam(c) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid dashboard_app_id") return } 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 } response.NoContent(c) } // Patch partially updates a dashboard app through Rails resource update. // PATCH /api/v1/accounts/:account_id/dashboard_apps/:id func (h *DashboardAppHandler) Patch(c *gin.Context) { id, err := parseDashboardAppIDParam(c) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid dashboard_app_id") return } 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 } app, err := h.svc.UpdateByAccountAndID(c.Request.Context(), accountID, id, req) if err != nil { applogger.L().Errorf("Patch dashboard app: %v", err) handleDashboardAppMutationError(c, err) return } c.JSON(http.StatusOK, serializeDashboardApp(app)) } // List returns all dashboard apps for an account. // GET /api/v1/accounts/:account_id/dashboard_apps // Chatwoot: json.array! @dashboard_apps — pure JSON array, no meta func (h *DashboardAppHandler) List(c *gin.Context) { accountID := getAccountID(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } apps, err := h.svc.ListByAccount(c.Request.Context(), accountID) if err != nil { applogger.L().Errorf("List dashboard apps: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to list dashboard apps") return } 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 parseDashboardAppIDParam(c *gin.Context) (uint, error) { if c.Param("id") != "" { return parseUintParam(c, "id") } return parseUintParam(c, "dashboard_app_id") } 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) ========== // Search searches dashboard apps by title keyword within an account. // GET /api/v1/accounts/:account_id/dashboard_apps/search?q=... func (h *DashboardAppHandler) Search(c *gin.Context) { accountID := getAccountID(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } query := c.Query("q") if query == "" { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, "search query 'q' is required") return } apps, err := h.svc.Search(c.Request.Context(), accountID, query) if err != nil { applogger.L().Errorf("Search dashboard apps: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to search dashboard apps") return } c.JSON(http.StatusOK, apps) } // GetWidgets returns all widgets in a dashboard app's content. // GET /api/v1/accounts/:account_id/dashboard_apps/:id/widgets func (h *DashboardAppHandler) GetWidgets(c *gin.Context) { id, err := parseDashboardAppIDParam(c) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid dashboard_app_id") return } widgets, err := h.svc.GetWidgets(c.Request.Context(), id) if err != nil { applogger.L().Errorf("Get widgets: %v", err) response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "dashboard app not found") return } response.OK(c, widgets) } // AddWidget adds a widget to a dashboard app's content array. // POST /api/v1/accounts/:account_id/dashboard_apps/:id/widgets func (h *DashboardAppHandler) AddWidget(c *gin.Context) { id, err := parseDashboardAppIDParam(c) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid dashboard_app_id") return } var req service.AddWidgetRequest if err := c.ShouldBindJSON(&req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } widgets, err := h.svc.AddWidget(c.Request.Context(), id, &req) if err != nil { applogger.L().Errorf("Add widget: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to add widget") return } response.OK(c, widgets) } // RemoveWidget removes a widget from a dashboard app's content array by index. // DELETE /api/v1/accounts/:account_id/dashboard_apps/:id/widgets/:widget_index func (h *DashboardAppHandler) RemoveWidget(c *gin.Context) { id, err := parseDashboardAppIDParam(c) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid dashboard_app_id") return } widgetIndex, err := parseUintParam(c, "widget_index") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid widget_index") return } widgets, err := h.svc.RemoveWidget(c.Request.Context(), id, int(widgetIndex)) if err != nil { applogger.L().Errorf("Remove widget: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to remove widget") return } response.OK(c, widgets) } // UpdateWidget updates a widget in a dashboard app's content array by index. // PUT /api/v1/accounts/:account_id/dashboard_apps/:id/widgets/:widget_index // GoChat extension — Chatwoot has no widget sub-resource CRUD. func (h *DashboardAppHandler) UpdateWidget(c *gin.Context) { id, err := parseDashboardAppIDParam(c) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid dashboard_app_id") return } widgetIndex, err := parseUintParam(c, "widget_index") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid widget_index") return } var req service.UpdateWidgetRequest if err := c.ShouldBindJSON(&req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } widgets, err := h.svc.UpdateWidget(c.Request.Context(), id, int(widgetIndex), &req) if err != nil { applogger.L().Errorf("Update widget: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to update widget") return } response.OK(c, widgets) }