Restructure the monorepo into clear top-level directories: - backend/: Go module root (cmd, internal, pkg, configs, migrations, docs/swagger, scripts, tests, go.mod, Makefile, .air.toml) - deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd - docs/: project documentation + reports/ (moved from repo root) - AGENTS.md: new AI coding-agent guide at repo root Update all references to the new layout: - Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root) - docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile, env_file ../../.env, volume mounts ../../backend:/app - deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile - CI: working-directory: backend for go commands, file deploy/docker/Dockerfile, coverage path backend/coverage.out, health_check backend/scripts/ - backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../ - README: architecture tree, quickstart, config paths updated Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh, gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to preserve history. Build, vet, SQLite tests, and docker compose config verified.
403 lines
13 KiB
Go
403 lines
13 KiB
Go
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)
|
|
}
|