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.
453 lines
13 KiB
Go
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
|
|
}
|