Files
gochat/backend/internal/service/dashboard_app_service.go
T
rogee aeddedf2a3 Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
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.
2026-07-07 14:44:12 +08:00

453 lines
13 KiB
Go

package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
applogger "github.com/gochat/gochat/pkg/logger"
)
// DashboardAppService implements business logic for DashboardApp CRUD.
// Reference: Chatwoot app/controllers/api/v1/accounts/dashboard_apps_controller.rb
type DashboardAppService struct {
repo *repository.DashboardAppRepo
}
func NewDashboardAppService(repo *repository.DashboardAppRepo) *DashboardAppService {
return &DashboardAppService{repo: repo}
}
// CreateDashboardAppRequest is the DTO for creating a dashboard app.
// Chatwoot permits: :title, content: [:url, :type]
// GoChat extensions: description, icon, url, kind, active
type CreateDashboardAppRequest struct {
Title string `json:"title" validate:"required,min=2"`
Description string `json:"description,omitempty"`
Icon string `json:"icon,omitempty"`
URL string `json:"url,omitempty"`
Kind string `json:"kind,omitempty"` // frame, link
Content json.RawMessage `json:"content,omitempty"`
Active *bool `json:"active,omitempty"` // pointer so nil = default true
}
// DashboardAppCreateWrapper wraps the create request under "dashboard_app" key.
// Chatwoot: params.require(:dashboard_app).permit(:title, content: [:url, :type])
type DashboardAppCreateWrapper struct {
DashboardApp CreateDashboardAppRequest `json:"dashboard_app"`
}
// UpdateDashboardAppRequest is the DTO for updating a dashboard app.
type UpdateDashboardAppRequest struct {
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
Icon string `json:"icon,omitempty"`
URL string `json:"url,omitempty"`
Kind string `json:"kind,omitempty"`
Content json.RawMessage `json:"content,omitempty"`
Active *bool `json:"active,omitempty"`
}
// DashboardAppUpdateWrapper wraps the update request under "dashboard_app" key.
// Chatwoot: params.require(:dashboard_app).permit(:title, content: [:url, :type])
type DashboardAppUpdateWrapper struct {
DashboardApp UpdateDashboardAppRequest `json:"dashboard_app"`
}
func (s *DashboardAppService) Create(ctx context.Context, accountID uint, userID *uint, req *CreateDashboardAppRequest) (*model.DashboardApp, error) {
// Validate content if provided
if len(req.Content) > 0 && string(req.Content) != "" {
if err := model.ValidateContent(req.Content); err != nil {
return nil, err
}
}
content := req.Content
if len(content) == 0 || string(content) == "" {
content = json.RawMessage(`[]`)
}
active := model.BoolPtr(true)
if req.Active != nil {
active = req.Active
}
kind := req.Kind
if kind == "" {
kind = "frame"
}
app := &model.DashboardApp{
AccountID: accountID,
UserID: userID,
Title: req.Title,
Description: req.Description,
Icon: req.Icon,
URL: req.URL,
Kind: kind,
Content: content,
Active: active,
}
if err := s.repo.Create(ctx, app); err != nil {
applogger.L().Errorf("Create dashboard app: %v", err)
return nil, err
}
return app, nil
}
func (s *DashboardAppService) GetByID(ctx context.Context, id uint) (*model.DashboardApp, error) {
app, err := s.repo.GetByID(ctx, id)
if err != nil {
applogger.L().Errorf("Get dashboard app: %v", err)
return nil, err
}
return app, nil
}
func (s *DashboardAppService) GetByAccountAndID(ctx context.Context, accountID, id uint) (*model.DashboardApp, error) {
app, err := s.repo.GetByAccountAndID(ctx, accountID, id)
if err != nil {
applogger.L().Errorf("Get dashboard app: %v", err)
return nil, err
}
return app, nil
}
func (s *DashboardAppService) Update(ctx context.Context, id uint, req *UpdateDashboardAppRequest) (*model.DashboardApp, error) {
app, err := s.repo.GetByID(ctx, id)
if err != nil {
applogger.L().Errorf("Get dashboard app for update: %v", err)
return nil, err
}
// Validate content if provided
if len(req.Content) > 0 && string(req.Content) != "" {
if err := model.ValidateContent(req.Content); err != nil {
return nil, err
}
}
if req.Title != "" {
app.Title = req.Title
}
if req.Description != "" {
app.Description = req.Description
}
if req.Icon != "" {
app.Icon = req.Icon
}
if req.URL != "" {
app.URL = req.URL
}
if req.Kind != "" {
app.Kind = req.Kind
}
if len(req.Content) > 0 && string(req.Content) != "" {
app.Content = req.Content
}
if req.Active != nil {
app.Active = req.Active
}
if err := s.repo.Update(ctx, app); err != nil {
applogger.L().Errorf("Update dashboard app: %v", err)
return nil, err
}
return app, nil
}
func (s *DashboardAppService) UpdateByAccountAndID(ctx context.Context, accountID, id uint, req *UpdateDashboardAppRequest) (*model.DashboardApp, error) {
app, err := s.repo.GetByAccountAndID(ctx, accountID, id)
if err != nil {
applogger.L().Errorf("Get dashboard app for update: %v", err)
return nil, err
}
if len(req.Content) > 0 && string(req.Content) != "" {
if err := model.ValidateContent(req.Content); err != nil {
return nil, err
}
}
if strings.TrimSpace(req.Title) != "" {
app.Title = req.Title
}
if req.Description != "" {
app.Description = req.Description
}
if req.Icon != "" {
app.Icon = req.Icon
}
if req.URL != "" {
app.URL = req.URL
}
if req.Kind != "" {
app.Kind = req.Kind
}
if len(req.Content) > 0 && string(req.Content) != "" {
app.Content = req.Content
}
if req.Active != nil {
app.Active = req.Active
}
if err := s.repo.Update(ctx, app); err != nil {
applogger.L().Errorf("Update dashboard app: %v", err)
return nil, err
}
return app, nil
}
func (s *DashboardAppService) Delete(ctx context.Context, id uint) error {
app, err := s.repo.GetByID(ctx, id)
if err != nil {
applogger.L().Errorf("Delete dashboard app: find failed: %v", err)
return err
}
if app == nil {
return fmt.Errorf("dashboard app not found")
}
if err := s.repo.Delete(ctx, id); err != nil {
applogger.L().Errorf("Delete dashboard app: %v", err)
return err
}
return nil
}
func (s *DashboardAppService) DeleteByAccountAndID(ctx context.Context, accountID, id uint) error {
app, err := s.repo.GetByAccountAndID(ctx, accountID, id)
if err != nil {
applogger.L().Errorf("Delete dashboard app: find failed: %v", err)
return err
}
if app == nil {
return fmt.Errorf("dashboard app not found")
}
if err := s.repo.DeleteByAccountAndID(ctx, accountID, id); err != nil {
applogger.L().Errorf("Delete dashboard app: %v", err)
return err
}
return nil
}
// ListByAccount returns all dashboard apps for an account.
// Chatwoot: Current.account.dashboard_apps — no pagination
func (s *DashboardAppService) ListByAccount(ctx context.Context, accountID uint) ([]model.DashboardApp, error) {
apps, err := s.repo.FindAllByAccountID(ctx, accountID)
if err != nil {
applogger.L().Errorf("List dashboard apps by account: %v", err)
return nil, err
}
return apps, nil
}
// ListByAccountPaginated returns paginated dashboard apps for an account.
func (s *DashboardAppService) ListByAccountPaginated(ctx context.Context, accountID uint, page, perPage int) ([]model.DashboardApp, int64, error) {
offset := (page - 1) * perPage
apps, count, err := s.repo.FindByAccountID(ctx, accountID, offset, perPage)
if err != nil {
applogger.L().Errorf("List dashboard apps by account: %v", err)
return nil, 0, err
}
return apps, count, nil
}
// ListActiveByAccount returns only active dashboard apps for an account.
// ListActiveByAccount returns all active dashboard apps for an account (no pagination).
// GoChat extension — Chatwoot has no separate active list endpoint.
func (s *DashboardAppService) ListActiveByAccount(ctx context.Context, accountID uint) ([]model.DashboardApp, error) {
apps, err := s.repo.FindAllActiveByAccountID(ctx, accountID)
if err != nil {
applogger.L().Errorf("List active dashboard apps by account: %v", err)
return nil, err
}
return apps, nil
}
// Search searches dashboard apps by title keyword within an account.
// GoChat extension — Chatwoot has no search endpoint.
func (s *DashboardAppService) Search(ctx context.Context, accountID uint, query string) ([]model.DashboardApp, error) {
apps, err := s.repo.SearchAll(ctx, accountID, query)
if err != nil {
applogger.L().Errorf("Search dashboard apps: %v", err)
return nil, err
}
return apps, nil
}
// SearchPaginated returns paginated search results.
func (s *DashboardAppService) SearchPaginated(ctx context.Context, accountID uint, query string, page, perPage int) ([]model.DashboardApp, int64, error) {
offset := (page - 1) * perPage
apps, count, err := s.repo.Search(ctx, accountID, query, offset, perPage)
if err != nil {
applogger.L().Errorf("Search dashboard apps: %v", err)
return nil, 0, err
}
return apps, count, nil
}
// ========== Widget Management ==========
// AddWidgetRequest is the DTO for adding a widget to a DashboardApp.
type AddWidgetRequest struct {
Type string `json:"type" validate:"required,eq=frame"`
URL string `json:"url" validate:"required,url"`
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
}
// UpdateWidgetRequest is the DTO for updating a widget in a DashboardApp.
type UpdateWidgetRequest struct {
Type string `json:"type,omitempty"`
URL string `json:"url,omitempty"`
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
}
// AddWidget adds a new widget to the DashboardApp's content array.
func (s *DashboardAppService) AddWidget(ctx context.Context, dashboardAppID uint, req *AddWidgetRequest) (*model.DashboardApp, error) {
app, err := s.repo.GetByID(ctx, dashboardAppID)
if err != nil {
return nil, err
}
widget := model.DashboardWidget{
Type: req.Type,
URL: req.URL,
ID: req.ID,
Name: req.Name,
}
// Validate the widget
if widget.Type != "frame" {
return nil, errors.New("widget type must be 'frame'")
}
if !model.IsValidHTTPURL(widget.URL) {
return nil, errors.New("widget url must be http/https URI")
}
// Parse existing content
var widgets []model.DashboardWidget
if len(app.Content) > 0 && string(app.Content) != "" && string(app.Content) != "[]" {
if err := json.Unmarshal(app.Content, &widgets); err != nil {
return nil, err
}
}
widgets = append(widgets, widget)
newContent, err := json.Marshal(widgets)
if err != nil {
return nil, err
}
app.Content = json.RawMessage(newContent)
if err := s.repo.Update(ctx, app); err != nil {
return nil, err
}
return app, nil
}
// UpdateWidget updates a specific widget (by index) in the DashboardApp's content array.
func (s *DashboardAppService) UpdateWidget(ctx context.Context, dashboardAppID uint, widgetIndex int, req *UpdateWidgetRequest) (*model.DashboardApp, error) {
app, err := s.repo.GetByID(ctx, dashboardAppID)
if err != nil {
return nil, err
}
var widgets []model.DashboardWidget
if len(app.Content) > 0 && string(app.Content) != "" && string(app.Content) != "[]" {
if err := json.Unmarshal(app.Content, &widgets); err != nil {
return nil, err
}
}
if widgetIndex < 0 || widgetIndex >= len(widgets) {
return nil, errors.New("widget index out of range")
}
w := widgets[widgetIndex]
if req.Type != "" {
if req.Type != "frame" {
return nil, errors.New("widget type must be 'frame'")
}
w.Type = req.Type
}
if req.URL != "" {
if !model.IsValidHTTPURL(req.URL) {
return nil, errors.New("widget url must be http/https URI")
}
w.URL = req.URL
}
if req.ID != "" {
w.ID = req.ID
}
if req.Name != "" {
w.Name = req.Name
}
widgets[widgetIndex] = w
newContent, err := json.Marshal(widgets)
if err != nil {
return nil, err
}
app.Content = json.RawMessage(newContent)
if err := s.repo.Update(ctx, app); err != nil {
return nil, err
}
return app, nil
}
// RemoveWidget removes a widget (by index) from the DashboardApp's content array.
func (s *DashboardAppService) RemoveWidget(ctx context.Context, dashboardAppID uint, widgetIndex int) (*model.DashboardApp, error) {
app, err := s.repo.GetByID(ctx, dashboardAppID)
if err != nil {
return nil, err
}
var widgets []model.DashboardWidget
if len(app.Content) > 0 && string(app.Content) != "" && string(app.Content) != "[]" {
if err := json.Unmarshal(app.Content, &widgets); err != nil {
return nil, err
}
}
if widgetIndex < 0 || widgetIndex >= len(widgets) {
return nil, errors.New("widget index out of range")
}
widgets = append(widgets[:widgetIndex], widgets[widgetIndex+1:]...)
newContent, err := json.Marshal(widgets)
if err != nil {
return nil, err
}
app.Content = json.RawMessage(newContent)
if err := s.repo.Update(ctx, app); err != nil {
return nil, err
}
return app, nil
}
// GetWidgets returns all widgets from a DashboardApp's content array.
func (s *DashboardAppService) GetWidgets(ctx context.Context, dashboardAppID uint) ([]model.DashboardWidget, error) {
app, err := s.repo.GetByID(ctx, dashboardAppID)
if err != nil {
return nil, err
}
var widgets []model.DashboardWidget
if len(app.Content) > 0 && string(app.Content) != "" && string(app.Content) != "[]" {
if err := json.Unmarshal(app.Content, &widgets); err != nil {
return nil, err
}
}
return widgets, nil
}