package service import ( "context" "encoding/json" "errors" "gorm.io/datatypes" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" applogger "github.com/gochat/gochat/pkg/logger" ) // CustomAttributeValueService handles setting and removing custom attribute values // on conversations and contacts. // Reference: Chatwoot app/controllers/api/v1/accounts/custom_attribute_definitions_controller.rb // The value operations merge/remove individual keys into the CustomAttributes jsonb field. type CustomAttributeValueService struct { defRepo *repository.CustomAttributeDefinitionRepo convRepo *repository.ConversationRepo contactRepo *repository.ContactRepo } // NewCustomAttributeValueService creates a new service. func NewCustomAttributeValueService( defRepo *repository.CustomAttributeDefinitionRepo, convRepo *repository.ConversationRepo, contactRepo *repository.ContactRepo, ) *CustomAttributeValueService { return &CustomAttributeValueService{ defRepo: defRepo, convRepo: convRepo, contactRepo: contactRepo, } } // SetAttributeValueRequest is the DTO for setting a custom attribute value. type SetAttributeValueRequest struct { AttributeName string `json:"attribute_name" validate:"required,min=1"` Value interface{} `json:"value" validate:"required"` } // validateAttributeDefinition checks that the attribute definition exists for the account and model. func (s *CustomAttributeValueService) validateAttributeDefinition(ctx context.Context, accountID uint, attributeName, attributeModel string) (*model.CustomAttributeDefinition, error) { defs, _, err := s.defRepo.FindByAccountAndModel(ctx, accountID, attributeModel, 0, 1000) if err != nil { return nil, err } for _, def := range defs { if def.AttributeName == attributeName { return &def, nil } } return nil, errors.New("custom attribute definition not found for this model type") } // mergeIntoJSON merges a single key-value pair into an existing datatypes.JSON object. // If the existing JSON is nil or empty, it creates a new map. func mergeIntoJSON(existing datatypes.JSON, key string, value interface{}) (datatypes.JSON, error) { var mapData map[string]interface{} if existing != nil && len(existing) > 0 { if err := json.Unmarshal(existing, &mapData); err != nil { // If unmarshal fails, start fresh mapData = make(map[string]interface{}) } } else { mapData = make(map[string]interface{}) } mapData[key] = value result, err := json.Marshal(mapData) if err != nil { return nil, err } return datatypes.JSON(result), nil } // removeFromJSON removes a single key from an existing datatypes.JSON object. // If the key doesn't exist, the JSON is returned unchanged. func removeFromJSON(existing datatypes.JSON, key string) (datatypes.JSON, error) { if existing == nil || len(existing) == 0 { return datatypes.JSON("{}"), nil } var mapData map[string]interface{} if err := json.Unmarshal(existing, &mapData); err != nil { return nil, err } delete(mapData, key) result, err := json.Marshal(mapData) if err != nil { return nil, err } return datatypes.JSON(result), nil } // SetConversationAttributeValue sets a custom attribute value on a conversation. // It validates the attribute definition exists for the account and "conversation" model, // then merges the value into the conversation's CustomAttributes jsonb field. func (s *CustomAttributeValueService) SetConversationAttributeValue(ctx context.Context, accountID, conversationID uint, req *SetAttributeValueRequest) (*model.Conversation, error) { // Validate attribute definition exists for conversation model def, err := s.validateAttributeDefinition(ctx, accountID, req.AttributeName, "conversation") if err != nil { return nil, err } // Validate value type matches attribute_type if err := validateValueType(def.AttributeType, req.Value); err != nil { return nil, err } // Fetch the conversation conversation, err := s.convRepo.FindByAccountAndID(ctx, accountID, conversationID) if err != nil { return nil, err } // Merge the new attribute value into existing custom attributes merged, mergeErr := mergeIntoJSON(conversation.CustomAttributes, req.AttributeName, req.Value) if mergeErr != nil { applogger.L().Errorf("Merge custom attribute value failed: %v", mergeErr) return nil, mergeErr } if err := s.convRepo.UpdateCustomAttributes(ctx, conversationID, merged); err != nil { applogger.L().Errorf("Update conversation custom attributes failed: %v", err) return nil, err } // Re-fetch to return updated state conversation, err = s.convRepo.FindByAccountAndID(ctx, accountID, conversationID) if err != nil { return nil, err } return conversation, nil } // RemoveConversationAttributeValue removes a custom attribute key from a conversation. func (s *CustomAttributeValueService) RemoveConversationAttributeValue(ctx context.Context, accountID, conversationID uint, attributeName string) (*model.Conversation, error) { // Validate attribute definition exists if _, err := s.validateAttributeDefinition(ctx, accountID, attributeName, "conversation"); err != nil { return nil, err } // Fetch the conversation conversation, err := s.convRepo.FindByAccountAndID(ctx, accountID, conversationID) if err != nil { return nil, err } // Remove the key from custom attributes updated, removeErr := removeFromJSON(conversation.CustomAttributes, attributeName) if removeErr != nil { applogger.L().Errorf("Remove custom attribute value failed: %v", removeErr) return nil, removeErr } if err := s.convRepo.UpdateCustomAttributes(ctx, conversationID, updated); err != nil { applogger.L().Errorf("Update conversation custom attributes failed: %v", err) return nil, err } // Re-fetch to return updated state conversation, err = s.convRepo.FindByAccountAndID(ctx, accountID, conversationID) if err != nil { return nil, err } return conversation, nil } // SetContactAttributeValue sets a custom attribute value on a contact. func (s *CustomAttributeValueService) SetContactAttributeValue(ctx context.Context, accountID, contactID uint, req *SetAttributeValueRequest) (*model.Contact, error) { // Validate attribute definition exists for contact model def, err := s.validateAttributeDefinition(ctx, accountID, req.AttributeName, "contact") if err != nil { return nil, err } // Validate value type matches attribute_type if err := validateValueType(def.AttributeType, req.Value); err != nil { return nil, err } // Fetch the contact contact, err := s.contactRepo.FindByAccountAndID(ctx, accountID, contactID) if err != nil { return nil, err } // Merge the new attribute value into existing custom attributes merged, mergeErr := mergeIntoJSON(contact.CustomAttributes, req.AttributeName, req.Value) if mergeErr != nil { applogger.L().Errorf("Merge custom attribute value failed: %v", mergeErr) return nil, mergeErr } if err := s.contactRepo.UpdateCustomAttributes(ctx, contactID, merged); err != nil { applogger.L().Errorf("Update contact custom attributes failed: %v", err) return nil, err } // Re-fetch to return updated state contact, err = s.contactRepo.FindByAccountAndID(ctx, accountID, contactID) if err != nil { return nil, err } return contact, nil } // RemoveContactAttributeValue removes a custom attribute key from a contact. func (s *CustomAttributeValueService) RemoveContactAttributeValue(ctx context.Context, accountID, contactID uint, attributeName string) (*model.Contact, error) { // Validate attribute definition exists if _, err := s.validateAttributeDefinition(ctx, accountID, attributeName, "contact"); err != nil { return nil, err } // Fetch the contact contact, err := s.contactRepo.FindByAccountAndID(ctx, accountID, contactID) if err != nil { return nil, err } // Remove the key from custom attributes updated, removeErr := removeFromJSON(contact.CustomAttributes, attributeName) if removeErr != nil { applogger.L().Errorf("Remove custom attribute value failed: %v", removeErr) return nil, removeErr } if err := s.contactRepo.UpdateCustomAttributes(ctx, contactID, updated); err != nil { applogger.L().Errorf("Update contact custom attributes failed: %v", err) return nil, err } // Re-fetch to return updated state contact, err = s.contactRepo.FindByAccountAndID(ctx, accountID, contactID) if err != nil { return nil, err } return contact, nil } // validateValueType checks that the provided value matches the expected attribute type. func validateValueType(attributeType string, value interface{}) error { switch attributeType { case "text", "link": if _, ok := value.(string); !ok { return errors.New("value must be a string for text/link attribute type") } case "number": // JSON numbers can be float or int — accept both switch value.(type) { case float64, int, int64, int32, float32: // OK default: return errors.New("value must be a number for number attribute type") } case "date": if _, ok := value.(string); !ok { return errors.New("value must be a string (date format) for date attribute type") } case "checkbox": if _, ok := value.(bool); !ok { return errors.New("value must be a boolean for checkbox attribute type") } case "list": // List can hold any JSON-serializable value return nil default: return nil // Unknown types are accepted } return nil }