Files
gochat/backend/internal/service/custom_attribute_definition_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

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)
}