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.
311 lines
12 KiB
Go
311 lines
12 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"gorm.io/datatypes"
|
|
|
|
"github.com/gochat/gochat/internal/model"
|
|
"github.com/gochat/gochat/internal/repository"
|
|
applogger "github.com/gochat/gochat/pkg/logger"
|
|
pkgvalidator "github.com/gochat/gochat/pkg/validator"
|
|
)
|
|
|
|
// 1:1 Chatwoot: attribute_key format regex — /\A[\p{L}\p{N}_.-]+\z/
|
|
// Unicode letters/digits + underscore + dot + hyphen
|
|
var attributeKeyFormatRegex = regexp.MustCompile(`^[\p{L}\p{N}_.-]+$`)
|
|
|
|
// 1:1 Chatwoot: STANDARD_ATTRIBUTES — reserved attribute keys per model
|
|
var StandardAttributes = map[string][]string{
|
|
"conversation": {"status", "priority", "assignee_id", "inbox_id", "team_id", "display_id", "campaign_id", "labels", "browser_language", "country_code", "referer", "created_at", "last_activity_at"},
|
|
"contact": {"name", "email", "phone_number", "identifier", "country_code", "city", "company_name", "created_at", "last_activity_at", "referer", "blocked"},
|
|
"company": {"name", "domain", "description", "contacts_count", "created_at", "updated_at", "last_activity_at"},
|
|
}
|
|
|
|
// CustomAttributeDefinitionService implements business logic for custom attribute definitions.
|
|
// Reference: Chatwoot app/controllers/api/v1/accounts/custom_attribute_definitions_controller.rb
|
|
type CustomAttributeDefinitionService struct {
|
|
repo *repository.CustomAttributeDefinitionRepo
|
|
}
|
|
|
|
// NewCustomAttributeDefinitionService creates a new service.
|
|
func NewCustomAttributeDefinitionService(repo *repository.CustomAttributeDefinitionRepo) *CustomAttributeDefinitionService {
|
|
return &CustomAttributeDefinitionService{repo: repo}
|
|
}
|
|
|
|
// CreateCustomAttributeDefinitionRequest is the DTO for creating a definition.
|
|
// Chatwoot compatibility: uses attribute_key, attribute_display_type, attribute_description
|
|
type CreateCustomAttributeDefinitionRequest struct {
|
|
AttributeKey string `json:"attribute_key" validate:"required,min=1"` // Chatwoot: attribute_key (maps to attribute_name)
|
|
AttributeDisplayName string `json:"attribute_display_name" validate:"required,min=1"`
|
|
AttributeDisplayType string `json:"attribute_display_type" validate:"required,oneof=text number currency percent link date list checkbox"`
|
|
AttributeModel string `json:"attribute_model" validate:"required,oneof=conversation_attribute contact_attribute company_attribute"`
|
|
DefaultValue datatypes.JSON `json:"default_value,omitempty"`
|
|
AttributeValues datatypes.JSON `json:"attribute_values,omitempty"` // Predefined valid values for list-type attributes
|
|
RegexPattern string `json:"regex_pattern,omitempty"` // Regex validation pattern
|
|
RegexCue string `json:"regex_cue,omitempty"` // Regex validation hint
|
|
AttributeDescription string `json:"attribute_description,omitempty"`
|
|
}
|
|
|
|
// UpdateCustomAttributeDefinitionRequest is the DTO for updating a definition.
|
|
// Chatwoot compatibility: uses attribute_key, attribute_display_type, attribute_description
|
|
type UpdateCustomAttributeDefinitionRequest struct {
|
|
AttributeDisplayName string `json:"attribute_display_name,omitempty" validate:"omitempty,min=1"`
|
|
AttributeDisplayType string `json:"attribute_display_type,omitempty" validate:"omitempty,oneof=text number currency percent link date list checkbox"`
|
|
DefaultValue datatypes.JSON `json:"default_value,omitempty"`
|
|
AttributeValues datatypes.JSON `json:"attribute_values,omitempty"`
|
|
RegexPattern string `json:"regex_pattern,omitempty"`
|
|
RegexCue string `json:"regex_cue,omitempty"`
|
|
AttributeDescription string `json:"attribute_description,omitempty"`
|
|
}
|
|
|
|
func (r *CreateCustomAttributeDefinitionRequest) UnmarshalJSON(data []byte) error {
|
|
type rawCreate struct {
|
|
AttributeKey string `json:"attribute_key"`
|
|
AttributeDisplayName string `json:"attribute_display_name"`
|
|
AttributeDisplayType json.RawMessage `json:"attribute_display_type"`
|
|
AttributeModel json.RawMessage `json:"attribute_model"`
|
|
DefaultValue datatypes.JSON `json:"default_value"`
|
|
AttributeValues datatypes.JSON `json:"attribute_values"`
|
|
RegexPattern string `json:"regex_pattern"`
|
|
RegexCue string `json:"regex_cue"`
|
|
AttributeDescription string `json:"attribute_description"`
|
|
}
|
|
var raw rawCreate
|
|
if err := json.Unmarshal(data, &raw); err != nil {
|
|
return err
|
|
}
|
|
r.AttributeKey = raw.AttributeKey
|
|
r.AttributeDisplayName = raw.AttributeDisplayName
|
|
r.AttributeDisplayType = normalizeAttributeDisplayType(raw.AttributeDisplayType)
|
|
r.AttributeModel = normalizeAttributeModel(raw.AttributeModel)
|
|
r.DefaultValue = raw.DefaultValue
|
|
r.AttributeValues = raw.AttributeValues
|
|
r.RegexPattern = raw.RegexPattern
|
|
r.RegexCue = raw.RegexCue
|
|
r.AttributeDescription = raw.AttributeDescription
|
|
return nil
|
|
}
|
|
|
|
func (r *UpdateCustomAttributeDefinitionRequest) UnmarshalJSON(data []byte) error {
|
|
type rawUpdate struct {
|
|
AttributeDisplayName string `json:"attribute_display_name"`
|
|
AttributeDisplayType json.RawMessage `json:"attribute_display_type"`
|
|
DefaultValue datatypes.JSON `json:"default_value"`
|
|
AttributeValues datatypes.JSON `json:"attribute_values"`
|
|
RegexPattern string `json:"regex_pattern"`
|
|
RegexCue string `json:"regex_cue"`
|
|
AttributeDescription string `json:"attribute_description"`
|
|
}
|
|
var raw rawUpdate
|
|
if err := json.Unmarshal(data, &raw); err != nil {
|
|
return err
|
|
}
|
|
r.AttributeDisplayName = raw.AttributeDisplayName
|
|
r.AttributeDisplayType = normalizeAttributeDisplayType(raw.AttributeDisplayType)
|
|
r.DefaultValue = raw.DefaultValue
|
|
r.AttributeValues = raw.AttributeValues
|
|
r.RegexPattern = raw.RegexPattern
|
|
r.RegexCue = raw.RegexCue
|
|
r.AttributeDescription = raw.AttributeDescription
|
|
return nil
|
|
}
|
|
|
|
func normalizeAttributeModel(raw json.RawMessage) string {
|
|
value := normalizeJSONEnum(raw)
|
|
switch value {
|
|
case "0", "conversation", "conversation_attribute":
|
|
return "conversation_attribute"
|
|
case "1", "contact", "contact_attribute":
|
|
return "contact_attribute"
|
|
case "2", "company", "company_attribute":
|
|
return "company_attribute"
|
|
default:
|
|
return value
|
|
}
|
|
}
|
|
|
|
func normalizeAttributeModelValue(value string) string {
|
|
data, _ := json.Marshal(value)
|
|
return normalizeAttributeModel(data)
|
|
}
|
|
|
|
func normalizeAttributeDisplayType(raw json.RawMessage) string {
|
|
value := normalizeJSONEnum(raw)
|
|
switch value {
|
|
case "0":
|
|
return "text"
|
|
case "1":
|
|
return "number"
|
|
case "2":
|
|
return "currency"
|
|
case "3":
|
|
return "percent"
|
|
case "4":
|
|
return "link"
|
|
case "5":
|
|
return "date"
|
|
case "6":
|
|
return "list"
|
|
case "7":
|
|
return "checkbox"
|
|
default:
|
|
return value
|
|
}
|
|
}
|
|
|
|
func normalizeAttributeDisplayTypeValue(value string) string {
|
|
data, _ := json.Marshal(value)
|
|
return normalizeAttributeDisplayType(data)
|
|
}
|
|
|
|
func normalizeJSONEnum(raw json.RawMessage) string {
|
|
if len(raw) == 0 || string(raw) == "null" {
|
|
return ""
|
|
}
|
|
var text string
|
|
if err := json.Unmarshal(raw, &text); err == nil {
|
|
return strings.TrimSpace(text)
|
|
}
|
|
var number int
|
|
if err := json.Unmarshal(raw, &number); err == nil {
|
|
return strconv.Itoa(number)
|
|
}
|
|
return strings.TrimSpace(string(raw))
|
|
}
|
|
|
|
// Create creates a new custom attribute definition for an account.
|
|
func (s *CustomAttributeDefinitionService) Create(ctx context.Context, accountID uint, req *CreateCustomAttributeDefinitionRequest) (*model.CustomAttributeDefinition, error) {
|
|
// 1:1 Chatwoot: normalize_attribute_fields — strip whitespace
|
|
req.AttributeKey = strings.TrimSpace(req.AttributeKey)
|
|
req.AttributeDisplayName = strings.TrimSpace(req.AttributeDisplayName)
|
|
req.AttributeModel = normalizeAttributeModelValue(req.AttributeModel)
|
|
req.AttributeDisplayType = normalizeAttributeDisplayTypeValue(req.AttributeDisplayType)
|
|
|
|
if err := pkgvalidator.ValidateStruct(req); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// 1:1 Chatwoot: attribute_key format validation — /\A[\p{L}\p{N}_.-]+\z/
|
|
if !attributeKeyFormatRegex.MatchString(req.AttributeKey) {
|
|
return nil, fmt.Errorf("attribute_key format invalid: must contain only letters, numbers, underscores, dots, and hyphens")
|
|
}
|
|
|
|
// 1:1 Chatwoot: attribute_must_not_conflict — key cannot be a standard attribute
|
|
modelKey := strings.TrimSuffix(req.AttributeModel, "_attribute") // "conversation", "contact", "company"
|
|
if standardAttrs, ok := StandardAttributes[modelKey]; ok {
|
|
for _, sa := range standardAttrs {
|
|
if req.AttributeKey == sa {
|
|
return nil, fmt.Errorf("attribute_key '%s' conflicts with standard attribute for %s", req.AttributeKey, modelKey)
|
|
}
|
|
}
|
|
}
|
|
|
|
def := &model.CustomAttributeDefinition{
|
|
AccountID: accountID,
|
|
AttributeName: req.AttributeKey,
|
|
AttributeDisplayName: req.AttributeDisplayName,
|
|
AttributeType: req.AttributeDisplayType,
|
|
AttributeModel: req.AttributeModel,
|
|
DefaultValue: req.DefaultValue,
|
|
AttributeValues: req.AttributeValues,
|
|
RegexPattern: req.RegexPattern,
|
|
RegexCue: req.RegexCue,
|
|
Description: req.AttributeDescription,
|
|
}
|
|
|
|
if err := s.repo.Create(ctx, def); err != nil {
|
|
applogger.L().Errorf("Create custom attribute definition failed: %v", err)
|
|
return nil, err
|
|
}
|
|
|
|
return def, nil
|
|
}
|
|
|
|
// Get retrieves a definition by ID, verifying it belongs to the account.
|
|
func (s *CustomAttributeDefinitionService) Get(ctx context.Context, accountID, id uint) (*model.CustomAttributeDefinition, error) {
|
|
def, err := s.repo.GetByID(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if def.AccountID != accountID {
|
|
return nil, errors.New("custom attribute definition not found")
|
|
}
|
|
return def, nil
|
|
}
|
|
|
|
// List retrieves definitions for an account, optionally filtered by attribute_model.
|
|
func (s *CustomAttributeDefinitionService) List(ctx context.Context, accountID uint, attributeModel string, offset, limit int) ([]model.CustomAttributeDefinition, int64, error) {
|
|
attributeModel = normalizeAttributeModelValue(attributeModel)
|
|
if attributeModel != "" {
|
|
return s.repo.FindByAccountAndModel(ctx, accountID, attributeModel, offset, limit)
|
|
}
|
|
return s.repo.FindByAccount(ctx, accountID, offset, limit)
|
|
}
|
|
|
|
// Update modifies a definition, verifying it belongs to the account.
|
|
func (s *CustomAttributeDefinitionService) Update(ctx context.Context, accountID, id uint, req *UpdateCustomAttributeDefinitionRequest) (*model.CustomAttributeDefinition, error) {
|
|
req.AttributeDisplayType = normalizeAttributeDisplayTypeValue(req.AttributeDisplayType)
|
|
req.AttributeDisplayName = strings.TrimSpace(req.AttributeDisplayName)
|
|
|
|
if err := pkgvalidator.ValidateStruct(req); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
def, err := s.repo.GetByID(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if def.AccountID != accountID {
|
|
return nil, errors.New("custom attribute definition not found")
|
|
}
|
|
|
|
if req.AttributeDisplayName != "" {
|
|
def.AttributeDisplayName = req.AttributeDisplayName
|
|
}
|
|
if req.AttributeDisplayType != "" {
|
|
def.AttributeType = req.AttributeDisplayType
|
|
}
|
|
if req.DefaultValue != nil {
|
|
def.DefaultValue = req.DefaultValue
|
|
}
|
|
if req.AttributeValues != nil {
|
|
def.AttributeValues = req.AttributeValues
|
|
}
|
|
if req.RegexPattern != "" {
|
|
def.RegexPattern = req.RegexPattern
|
|
}
|
|
if req.RegexCue != "" {
|
|
def.RegexCue = req.RegexCue
|
|
}
|
|
if req.AttributeDescription != "" {
|
|
def.Description = req.AttributeDescription
|
|
}
|
|
|
|
if err := s.repo.Update(ctx, def); err != nil {
|
|
applogger.L().Errorf("Update custom attribute definition failed: %v", err)
|
|
return nil, err
|
|
}
|
|
|
|
return def, nil
|
|
}
|
|
|
|
// Delete soft-deletes a definition, verifying it belongs to the account.
|
|
func (s *CustomAttributeDefinitionService) Delete(ctx context.Context, accountID, id uint) error {
|
|
def, err := s.repo.GetByID(ctx, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if def.AccountID != accountID {
|
|
return errors.New("custom attribute definition not found")
|
|
}
|
|
|
|
return s.repo.Delete(ctx, id)
|
|
}
|