Files
gochat/internal/repository/contact_repo.go
T

845 lines
30 KiB
Go

package repository
import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"gorm.io/datatypes"
"gorm.io/gorm"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/search"
)
// ContactRepo implements GORM repository for Contact.
// Reference: Chatwoot app/models/contact.rb + contact_inboxes join
type ContactRepo struct {
db *gorm.DB
}
// NewContactRepo creates a new Contact repository.
func NewContactRepo(db *gorm.DB) *ContactRepo {
return &ContactRepo{db: db}
}
// FindByID retrieves a contact by primary key.
func (r *ContactRepo) FindByID(ctx context.Context, id uint) (*model.Contact, error) {
var contact model.Contact
err := r.db.WithContext(ctx).First(&contact, id).Error
if err != nil {
return nil, err
}
return &contact, nil
}
func (r *ContactRepo) DB() *gorm.DB {
return r.db
}
// FindByAccount retrieves all contacts for an account with optional sort.
// sort: default "last_activity_at DESC, id DESC", alternatives: "name ASC", "email ASC", "created_at DESC"
func (r *ContactRepo) FindByAccount(ctx context.Context, accountID uint, offset, limit int, sort string, labels ...[]string) ([]model.Contact, int64, error) {
var contacts []model.Contact
var total int64
labelFilter := firstContactLabelFilter(labels)
base := r.db.WithContext(ctx).Model(&model.Contact{}).Where("contacts.account_id = ?", accountID)
base = r.applyResolvedContactsScope(ctx, base, accountID)
base = applyContactLabelFilter(base, accountID, labelFilter)
countDB := base
if len(labelFilter) > 0 {
countDB = countDB.Distinct("contacts.id")
}
if err := countDB.Count(&total).Error; err != nil {
return nil, 0, err
}
orderClause := resolveContactSort(sort)
queryDB := base
if len(labelFilter) > 0 {
queryDB = queryDB.Distinct("contacts.*")
}
err := queryDB.
Offset(offset).Limit(limit).Order(orderClause).
Find(&contacts).Error
return contacts, total, err
}
// Search searches contacts by name, email, or phone number with optional sort.
func (r *ContactRepo) Search(ctx context.Context, accountID uint, query string, offset, limit int, sort string, searchMode search.SearchMode, labels ...[]string) ([]model.Contact, int64, error) {
var contacts []model.Contact
var total int64
condition := r.db.WithContext(ctx).Model(&model.Contact{}).
Where("contacts.account_id = ?", accountID)
labelFilter := firstContactLabelFilter(labels)
condition = applyContactLabelFilter(condition, accountID, labelFilter)
if query != "" {
if searchMode == search.SearchModeTrigram {
// pg_trgm fuzzy match on contact fields
condition = condition.Where("name % ? OR email % ? OR phone_number ILIKE ? OR identifier % ?",
query, query, query, query)
} else {
// Case-insensitive substring match that works on PostgreSQL and SQLite tests.
likeQuery := "%" + query + "%"
condition = condition.Where("LOWER(name) LIKE LOWER(?) OR LOWER(email) LIKE LOWER(?) OR LOWER(phone_number) LIKE LOWER(?) OR LOWER(identifier) LIKE LOWER(?)",
likeQuery, likeQuery, likeQuery, likeQuery)
}
}
countDB := condition
if len(labelFilter) > 0 {
countDB = countDB.Distinct("contacts.id")
}
if err := countDB.Count(&total).Error; err != nil {
return nil, 0, err
}
orderClause := resolveContactSort(sort)
queryDB := condition
if len(labelFilter) > 0 {
queryDB = queryDB.Distinct("contacts.*")
}
err := queryDB.Offset(offset).Limit(limit).Order(orderClause).
Find(&contacts).Error
return contacts, total, err
}
func firstContactLabelFilter(filters [][]string) []string {
if len(filters) == 0 {
return nil
}
return normalizeContactLabels(filters[0])
}
func normalizeContactLabels(labels []string) []string {
seen := map[string]struct{}{}
result := make([]string, 0, len(labels))
for _, label := range labels {
label = strings.TrimSpace(label)
if label == "" {
continue
}
if _, ok := seen[label]; ok {
continue
}
seen[label] = struct{}{}
result = append(result, label)
}
return result
}
func applyContactLabelFilter(q *gorm.DB, accountID uint, labels []string) *gorm.DB {
labels = normalizeContactLabels(labels)
if len(labels) == 0 {
return q
}
return q.Joins("JOIN contact_labels ON contact_labels.contact_id = contacts.id").
Joins("JOIN tags ON tags.id = contact_labels.tag_id").
Where("contact_labels.account_id = ? AND tags.account_id = ? AND tags.name IN ?", accountID, accountID, labels)
}
func (r *ContactRepo) applyResolvedContactsScope(ctx context.Context, q *gorm.DB, accountID uint) *gorm.DB {
if r.contactCRMV2Enabled(ctx, accountID) {
return q.Where("contacts.contact_type = ?", "lead")
}
return q.Where("(contacts.email <> '' OR contacts.phone_number <> '' OR contacts.identifier <> '')")
}
func (r *ContactRepo) contactCRMV2Enabled(ctx context.Context, accountID uint) bool {
var account model.Account
if err := r.db.WithContext(ctx).Select("feature_flags").Where("id = ?", accountID).First(&account).Error; err != nil {
return false
}
return contactFeatureFlagEnabled(account.FeatureFlags, "crm_v2")
}
func contactFeatureFlagEnabled(raw, flag string) bool {
raw = strings.TrimSpace(raw)
if raw == "" {
return false
}
objectFlags := map[string]bool{}
if err := json.Unmarshal([]byte(raw), &objectFlags); err == nil {
return objectFlags[flag]
}
arrayFlags := []string{}
if err := json.Unmarshal([]byte(raw), &arrayFlags); err == nil {
for _, item := range arrayFlags {
if item == flag {
return true
}
}
return false
}
for _, item := range strings.Split(raw, ",") {
if strings.TrimSpace(item) == flag {
return true
}
}
return false
}
// resolveContactSort maps a sort parameter to a SQL ORDER BY clause.
// Supported values: "name", "email", "created_at", "last_activity_at" (default).
// Direction defaults to DESC for date fields, ASC for name/email.
func resolveContactSort(sort string) string {
allowedSorts := map[string]string{
"name": "contacts.name ASC",
"email": "contacts.email ASC",
"created_at": "contacts.created_at DESC",
"last_activity_at": "contacts.last_activity_at DESC NULLS LAST, contacts.id DESC",
}
if clause, ok := allowedSorts[sort]; ok {
return clause
}
return "contacts.last_activity_at DESC NULLS LAST, contacts.id DESC"
}
// FindByAccountAndID retrieves a contact scoped to an account.
func (r *ContactRepo) FindByAccountAndID(ctx context.Context, accountID, id uint) (*model.Contact, error) {
var contact model.Contact
err := r.db.WithContext(ctx).Where("account_id = ? AND id = ?", accountID, id).First(&contact).Error
if err != nil {
return nil, err
}
return &contact, nil
}
// Create inserts a new contact.
func (r *ContactRepo) Create(ctx context.Context, contact *model.Contact) error {
return r.db.WithContext(ctx).Create(contact).Error
}
// Update modifies an existing contact.
func (r *ContactRepo) Update(ctx context.Context, contact *model.Contact) error {
return r.db.WithContext(ctx).Save(contact).Error
}
// Delete soft-deletes a contact.
func (r *ContactRepo) Delete(ctx context.Context, id uint) error {
return r.db.WithContext(ctx).Delete(&model.Contact{}, id).Error
}
// FindByEmail retrieves a contact by email within an account.
func (r *ContactRepo) FindByEmail(ctx context.Context, accountID uint, email string) (*model.Contact, error) {
var contact model.Contact
err := r.db.WithContext(ctx).Where("account_id = ? AND email = ?", accountID, email).First(&contact).Error
if err != nil {
return nil, err
}
return &contact, nil
}
// FindByIdentifier retrieves a contact by external identifier within an account.
func (r *ContactRepo) FindByIdentifier(ctx context.Context, accountID uint, identifier string) (*model.Contact, error) {
var contact model.Contact
err := r.db.WithContext(ctx).Where("account_id = ? AND identifier = ?", accountID, identifier).First(&contact).Error
if err != nil {
return nil, err
}
return &contact, nil
}
// CountByAccount returns total number of contacts in an account.
func (r *ContactRepo) CountByAccount(ctx context.Context, accountID uint) (int64, error) {
var total int64
err := r.db.WithContext(ctx).Model(&model.Contact{}).Where("account_id = ?", accountID).Count(&total).Error
return total, err
}
// FindActive retrieves contacts with recent activity (last_activity_at within threshold).
// Reference: Chatwoot contacts#active — filters contacts who had conversations recently.
func (r *ContactRepo) FindActive(ctx context.Context, accountID uint, offset, limit int, sort string) ([]model.Contact, int64, error) {
var contacts []model.Contact
var total int64
countDB := r.db.WithContext(ctx).Model(&model.Contact{}).
Where("account_id = ? AND last_activity_at IS NOT NULL", accountID)
if err := countDB.Count(&total).Error; err != nil {
return nil, 0, err
}
orderClause := resolveContactSort(sort)
err := r.db.WithContext(ctx).
Where("account_id = ? AND last_activity_at IS NOT NULL", accountID).
Offset(offset).Limit(limit).Order(orderClause).
Find(&contacts).Error
return contacts, total, err
}
// FindAllForExport retrieves all contacts for an account (no pagination, for CSV export).
// Reference: Chatwoot contacts#export
func (r *ContactRepo) FindAllForExport(ctx context.Context, accountID uint) ([]model.Contact, error) {
return r.FindForExport(ctx, accountID, ContactFilterParams{})
}
// FindForExport retrieves all contacts matching export filters without pagination.
// Reference: Chatwoot Account::ContactsExportJob#contacts.
func (r *ContactRepo) FindForExport(ctx context.Context, accountID uint, params ContactFilterParams) ([]model.Contact, error) {
var contacts []model.Contact
q := r.exportQuery(ctx, accountID, params)
err := q.Distinct("contacts.*").Order("contacts.id ASC").Find(&contacts).Error
return contacts, err
}
func (r *ContactRepo) exportQuery(ctx context.Context, accountID uint, params ContactFilterParams) *gorm.DB {
q := r.db.WithContext(ctx).Model(&model.Contact{}).Where("contacts.account_id = ?", accountID)
q = r.applyResolvedContactsScope(ctx, q, accountID)
if params.ContactType != "" {
q = q.Where("contacts.contact_type = ?", params.ContactType)
}
if params.ContactSource != "" {
q = q.Where("contacts.source_id = ?", params.ContactSource)
}
if params.InboxID != nil {
q = q.Joins("JOIN contact_inboxes ON contact_inboxes.contact_id = contacts.id AND contact_inboxes.inbox_id = ?", *params.InboxID)
}
if params.Labels != "" {
q = applyContactLabelFilter(q, accountID, strings.Split(params.Labels, ","))
}
if params.Status == "active" {
q = q.Where("contacts.last_activity_at IS NOT NULL")
} else if params.Status == "inactive" {
q = q.Where("contacts.last_activity_at IS NULL")
}
if params.UpdatedWithin != nil {
threshold := time.Now().Add(-time.Duration(*params.UpdatedWithin) * time.Second)
q = q.Where("contacts.updated_at >= ?", threshold)
}
return q
}
// ContactLabelsByContactIDs returns approved contact label names grouped by contact ID.
func (r *ContactRepo) ContactLabelsByContactIDs(ctx context.Context, accountID uint, contactIDs []uint) (map[uint][]string, error) {
result := make(map[uint][]string, len(contactIDs))
if len(contactIDs) == 0 {
return result, nil
}
type row struct {
ContactID uint
Name string
}
var rows []row
err := r.db.WithContext(ctx).
Table("contact_labels").
Select("contact_labels.contact_id, tags.name").
Joins("JOIN tags ON tags.id = contact_labels.tag_id AND tags.account_id = contact_labels.account_id").
Where("contact_labels.account_id = ? AND contact_labels.contact_id IN ?", accountID, contactIDs).
Order("tags.name ASC").
Scan(&rows).Error
if err != nil {
return nil, err
}
for _, r := range rows {
result[r.ContactID] = append(result[r.ContactID], r.Name)
}
return result, nil
}
// UpdateCustomAttributes updates only the custom_attributes JSON field of a contact.
// Reference: Chatwoot contacts#update_custom_attributes
func (r *ContactRepo) UpdateCustomAttributes(ctx context.Context, id uint, customAttrs datatypes.JSON) error {
return r.db.WithContext(ctx).Model(&model.Contact{}).
Where("id = ?", id).
Update("custom_attributes", customAttrs).Error
}
// DeleteCustomAttributes clears the custom_attributes field of a contact.
// Reference: Chatwoot contacts#destroy_custom_attributes
func (r *ContactRepo) DeleteCustomAttributes(ctx context.Context, id uint) error {
return r.db.WithContext(ctx).Model(&model.Contact{}).
Where("id = ?", id).
Update("custom_attributes", datatypes.JSON("{}")).Error
}
// ContactFilterParams holds filter criteria for the POST /contacts/filter endpoint.
// Reference: Chatwoot ContactFilterService#perform
type ContactFilterParams struct {
Sort string `json:"sort,omitempty" form:"sort"`
ContactType string `json:"contact_type,omitempty" form:"contact_type"`
ContactSource string `json:"contact_source,omitempty" form:"contact_source"`
InboxID *uint `json:"inbox_id,omitempty" form:"inbox_id"`
Labels string `json:"labels,omitempty" form:"labels"`
Status string `json:"status,omitempty" form:"status"` // active, inactive
UpdatedWithin *int `json:"updated_within,omitempty" form:"updated_within"` // seconds
Payload []ContactFilterCondition `json:"payload,omitempty" form:"-"`
}
type ContactFilterCondition struct {
AttributeKey string `json:"attribute_key"`
FilterOperator string `json:"filter_operator"`
Values []any `json:"values"`
QueryOperator string `json:"query_operator,omitempty"`
CustomAttributeType string `json:"custom_attribute_type,omitempty"`
}
// Filter retrieves contacts matching advanced filter criteria with pagination.
// Reference: Chatwoot ContactFilterService#perform — filters by contact_type, source,
// labels, inbox, status, updated_within, and applies sort + pagination.
func (r *ContactRepo) Filter(ctx context.Context, accountID uint, params ContactFilterParams, offset, limit int) ([]model.Contact, int64, error) {
var contacts []model.Contact
var total int64
q := r.db.WithContext(ctx).Model(&model.Contact{}).Where("contacts.account_id = ?", accountID)
q = r.applyResolvedContactsScope(ctx, q, accountID)
usesDistinct := false
if len(params.Payload) > 0 {
advancedQuery, err := r.applyContactFilterPayload(ctx, q, accountID, params.Payload)
if err != nil {
return nil, 0, err
}
q = advancedQuery
}
// Apply contact type filter
if params.ContactType != "" {
q = q.Where("contact_type = ?", params.ContactType)
}
// Apply contact source filter
if params.ContactSource != "" {
q = q.Where("source_id = ?", params.ContactSource)
}
// Apply inbox filter via contact_inboxes join
if params.InboxID != nil {
q = q.Joins("JOIN contact_inboxes ON contact_inboxes.contact_id = contacts.id AND contact_inboxes.inbox_id = ?", *params.InboxID)
usesDistinct = true
}
// Apply labels filter (Chatwoot: contact label_list tagged_with any: true)
if params.Labels != "" {
q = applyContactLabelFilter(q, accountID, strings.Split(params.Labels, ","))
usesDistinct = true
}
// Apply status filter (active = has recent activity, inactive = no recent activity)
if params.Status == "active" {
q = q.Where("last_activity_at IS NOT NULL")
} else if params.Status == "inactive" {
q = q.Where("last_activity_at IS NULL")
}
// Apply updated_within filter (contacts updated in the last N seconds)
if params.UpdatedWithin != nil {
threshold := time.Now().Add(-time.Duration(*params.UpdatedWithin) * time.Second)
q = q.Where("updated_at >= ?", threshold)
}
// Count total matching records
countDB := q
if usesDistinct {
countDB = countDB.Distinct("contacts.id")
}
if err := countDB.Count(&total).Error; err != nil {
return nil, 0, err
}
orderClause := resolveContactSort(params.Sort)
queryDB := q
if usesDistinct {
queryDB = queryDB.Distinct("contacts.*")
}
err := queryDB.Offset(offset).Limit(limit).Order(orderClause).
Find(&contacts).Error
return contacts, total, err
}
func (r *ContactRepo) applyContactFilterPayload(ctx context.Context, query *gorm.DB, accountID uint, payload []ContactFilterCondition) (*gorm.DB, error) {
clauses := make([]string, 0, len(payload)*2)
args := make([]any, 0, len(payload))
for _, condition := range payload {
queryOperator := strings.ToUpper(strings.TrimSpace(condition.QueryOperator))
if queryOperator != "" && queryOperator != "AND" && queryOperator != "OR" {
return nil, fmt.Errorf("Query operator must be either \"AND\" or \"OR\".")
}
clause, clauseArgs, err := r.contactFilterClause(ctx, query, accountID, condition)
if err != nil {
return nil, err
}
if len(clauses) > 0 && !contactFilterIsQueryOperator(clauses[len(clauses)-1]) {
clauses = append(clauses, "AND")
}
clauses = append(clauses, clause)
args = append(args, clauseArgs...)
if queryOperator != "" {
clauses = append(clauses, queryOperator)
}
}
if len(clauses) == 0 {
return query, nil
}
if contactFilterIsQueryOperator(clauses[len(clauses)-1]) {
clauses = clauses[:len(clauses)-1]
}
return query.Where(strings.Join(clauses, " "), args...), nil
}
func (r *ContactRepo) contactFilterClause(ctx context.Context, query *gorm.DB, accountID uint, condition ContactFilterCondition) (string, []any, error) {
attribute := strings.TrimSpace(condition.AttributeKey)
operator := strings.TrimSpace(condition.FilterOperator)
values := contactFilterStringValues(condition.Values)
values = contactFilterNormalizeValues(attribute, values)
if attribute == "labels" {
return contactFilterLabelsClause(accountID, operator, values)
}
if attribute == "created_at" || attribute == "last_activity_at" {
return contactFilterDateClause(attribute, operator, values)
}
if additional, ok := contactFilterAdditionalAttribute(attribute); ok {
expr, exprArgs := contactFilterJSONExtract(query, "contacts.additional_attributes", attribute)
if additional.caseInsensitive {
expr = "LOWER(" + expr + ")"
}
return contactFilterBuildClause(attribute, expr, exprArgs, operator, values, additional.allowedOperators, false)
}
if column, allowedOperators, ok := contactFilterColumn(attribute); ok {
return contactFilterBuildClause(attribute, column, nil, operator, values, allowedOperators, false)
}
def, defErr := r.findContactFilterCustomAttributeDefinition(ctx, accountID, attribute, condition.CustomAttributeType)
if defErr != nil {
return "", nil, defErr
}
if def == nil {
return "", nil, fmt.Errorf("Invalid attribute key - [%s]", attribute)
}
expr, exprArgs := contactFilterJSONExtract(query, "contacts.custom_attributes", attribute)
return contactFilterCustomAttributeClause(attribute, expr, exprArgs, operator, values, def.AttributeType)
}
func contactFilterColumn(attribute string) (string, []string, bool) {
switch attribute {
case "name", "email":
return "LOWER(contacts." + attribute + ")", []string{"equal_to", "not_equal_to", "contains", "does_not_contain"}, true
case "phone_number":
return "contacts.phone_number", []string{"equal_to", "not_equal_to", "contains", "does_not_contain", "starts_with"}, true
case "identifier":
return "LOWER(contacts.identifier)", []string{"equal_to", "not_equal_to"}, true
case "blocked":
return "contacts.blocked", []string{"equal_to", "not_equal_to"}, true
default:
return "", nil, false
}
}
type contactAdditionalFilter struct {
allowedOperators []string
caseInsensitive bool
}
func contactFilterAdditionalAttribute(attribute string) (contactAdditionalFilter, bool) {
switch attribute {
case "country_code":
return contactAdditionalFilter{allowedOperators: []string{"equal_to", "not_equal_to"}, caseInsensitive: true}, true
case "city", "company_name":
return contactAdditionalFilter{allowedOperators: []string{"equal_to", "not_equal_to", "contains", "does_not_contain"}, caseInsensitive: true}, true
case "referer":
return contactAdditionalFilter{allowedOperators: []string{"equal_to", "not_equal_to", "contains", "does_not_contain"}}, true
default:
return contactAdditionalFilter{}, false
}
}
func (r *ContactRepo) findContactFilterCustomAttributeDefinition(ctx context.Context, accountID uint, attribute, customAttributeType string) (*model.CustomAttributeDefinition, error) {
attributeModel := strings.TrimSpace(customAttributeType)
if attributeModel == "" || attributeModel == "contact" {
attributeModel = "contact_attribute"
}
if attributeModel != "contact_attribute" {
return nil, fmt.Errorf("Invalid attribute key - [%s]", attribute)
}
var def model.CustomAttributeDefinition
err := r.db.WithContext(ctx).
Where("account_id = ? AND attribute_name = ? AND attribute_model IN ?", accountID, attribute, []string{"contact_attribute", "contact"}).
First(&def).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &def, nil
}
func contactFilterLabelsClause(accountID uint, operator string, values []string) (string, []any, error) {
allowedOperators := []string{"equal_to", "not_equal_to", "is_present", "is_not_present"}
if !contactFilterOperatorAllowed(operator, allowedOperators) {
return "", nil, fmt.Errorf("Invalid operator. The allowed operators for labels are [%s].", strings.Join(allowedOperators, ","))
}
if operator != "is_present" && operator != "is_not_present" && len(values) == 0 {
return "", nil, fmt.Errorf("Invalid value for labels")
}
base := "SELECT 1 FROM contact_labels JOIN tags ON tags.id = contact_labels.tag_id WHERE contact_labels.contact_id = contacts.id AND contact_labels.account_id = ? AND tags.account_id = ?"
args := []any{accountID, accountID}
switch operator {
case "equal_to":
args = append(args, values)
return "EXISTS (" + base + " AND tags.name IN ?)", args, nil
case "not_equal_to":
args = append(args, values)
return "NOT EXISTS (" + base + " AND tags.name IN ?)", args, nil
case "is_present":
return "EXISTS (" + base + ")", args, nil
case "is_not_present":
return "NOT EXISTS (" + base + ")", args, nil
default:
return "", nil, fmt.Errorf("Invalid operator. The allowed operators for labels are [%s].", strings.Join(allowedOperators, ","))
}
}
func contactFilterDateClause(attribute, operator string, values []string) (string, []any, error) {
allowedOperators := []string{"is_greater_than", "is_less_than", "days_before"}
if !contactFilterOperatorAllowed(operator, allowedOperators) {
return "", nil, fmt.Errorf("Invalid operator. The allowed operators for %s are [%s].", attribute, strings.Join(allowedOperators, ","))
}
if len(values) == 0 {
return "", nil, fmt.Errorf("Invalid value for %s", attribute)
}
value, err := contactFilterDateValue(attribute, operator, values[0])
if err != nil {
return "", nil, fmt.Errorf("Invalid value for %s", attribute)
}
comparison := ">"
if operator == "is_less_than" || operator == "days_before" {
comparison = "<"
}
if attribute == "last_activity_at" {
return "contacts.last_activity_at " + comparison + " ?", []any{value.Unix()}, nil
}
return "contacts.created_at " + comparison + " ?", []any{value}, nil
}
func contactFilterDateValue(attribute, operator, value string) (time.Time, error) {
if operator == "days_before" {
days, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil {
return time.Time{}, err
}
return time.Now().UTC().Truncate(24*time.Hour).AddDate(0, 0, -days), nil
}
if parsed, err := time.Parse(time.RFC3339, value); err == nil {
return parsed, nil
}
if parsed, err := time.Parse("2006-01-02", value); err == nil {
return parsed, nil
}
if attribute == "last_activity_at" {
seconds, err := strconv.ParseInt(value, 10, 64)
if err == nil {
return time.Unix(seconds, 0).UTC(), nil
}
}
return time.Time{}, fmt.Errorf("invalid date")
}
func contactFilterBuildClause(attribute, expression string, expressionArgs []any, operator string, values []string, allowedOperators []string, includeNullForNotEqual bool) (string, []any, error) {
if !contactFilterOperatorAllowed(operator, allowedOperators) {
return "", nil, fmt.Errorf("Invalid operator. The allowed operators for %s are [%s].", attribute, strings.Join(allowedOperators, ","))
}
if len(values) == 0 {
return "", nil, fmt.Errorf("Invalid value for %s", attribute)
}
args := append([]any{}, expressionArgs...)
switch operator {
case "equal_to":
args = append(args, contactFilterCoerceValue(attribute, values[0]))
return expression + " = ?", args, nil
case "not_equal_to":
args = append(args, contactFilterCoerceValue(attribute, values[0]))
if includeNullForNotEqual {
args = append(args, expressionArgs...)
return "(" + expression + " != ? OR " + expression + " IS NULL)", args, nil
}
return expression + " != ?", args, nil
case "contains", "does_not_contain", "starts_with":
clause, likeArgs := contactFilterLikeClause(expression, expressionArgs, values, operator)
return clause, likeArgs, nil
default:
return "", nil, fmt.Errorf("Invalid operator. The allowed operators for %s are [%s].", attribute, strings.Join(allowedOperators, ","))
}
}
func contactFilterLikeClause(expression string, expressionArgs []any, values []string, operator string) (string, []any) {
clauses := make([]string, 0, len(values))
args := make([]any, 0, len(values)*(len(expressionArgs)+1))
negate := operator == "does_not_contain"
likeOperator := "LIKE"
joiner := " OR "
if negate {
likeOperator = "NOT LIKE"
joiner = " AND "
}
for _, value := range values {
pattern := "%" + strings.ToLower(value) + "%"
if operator == "starts_with" {
pattern = strings.ToLower(value) + "%"
}
clauses = append(clauses, "LOWER("+expression+") "+likeOperator+" ?")
args = append(args, expressionArgs...)
args = append(args, pattern)
}
return "(" + strings.Join(clauses, joiner) + ")", args
}
func contactFilterJSONExtract(query *gorm.DB, column, key string) (string, []any) {
dialect := ""
if query != nil && query.Dialector != nil {
dialect = query.Dialector.Name()
}
switch dialect {
case "sqlite":
return "json_extract(" + column + ", ?)", []any{"$." + key}
case "mysql":
return "JSON_UNQUOTE(JSON_EXTRACT(" + column + ", ?))", []any{"$." + key}
default:
return column + " ->> ?", []any{key}
}
}
func contactFilterCustomAttributeOperators(attributeType string) []string {
switch strings.TrimSpace(attributeType) {
case "text", "link", "list", "":
return []string{"equal_to", "not_equal_to", "contains", "does_not_contain"}
case "date", "number":
return []string{"equal_to", "not_equal_to", "is_greater_than", "is_less_than", "days_before"}
default:
return []string{"equal_to", "not_equal_to"}
}
}
func contactFilterCustomAttributeClause(attribute, expression string, expressionArgs []any, operator string, values []string, attributeType string) (string, []any, error) {
allowedOperators := contactFilterCustomAttributeOperators(attributeType)
if !contactFilterOperatorAllowed(operator, allowedOperators) {
return "", nil, fmt.Errorf("Invalid operator. The allowed operators for %s are [%s].", attribute, strings.Join(allowedOperators, ","))
}
if operator == "is_present" {
return expression + " IS NOT NULL", append([]any{}, expressionArgs...), nil
}
if operator == "is_not_present" {
return expression + " IS NULL", append([]any{}, expressionArgs...), nil
}
if len(values) == 0 {
return "", nil, fmt.Errorf("Invalid value for %s", attribute)
}
switch strings.TrimSpace(attributeType) {
case "date":
value, err := contactFilterDateValue(attribute, operator, values[0])
if err != nil {
return "", nil, fmt.Errorf("Invalid value for %s", attribute)
}
comparison := contactFilterComparisonOperator(operator)
if comparison == "" {
return contactFilterBuildClause(attribute, expression, expressionArgs, operator, values, allowedOperators, true)
}
args := append([]any{}, expressionArgs...)
args = append(args, value.Format("2006-01-02"))
return expression + " " + comparison + " ?", args, nil
case "number":
comparison := contactFilterComparisonOperator(operator)
if comparison == "" {
return contactFilterBuildClause(attribute, expression, expressionArgs, operator, values, allowedOperators, true)
}
args := append([]any{}, expressionArgs...)
args = append(args, values[0])
return "CAST(" + expression + " AS NUMERIC) " + comparison + " ?", args, nil
default:
expression = contactFilterCustomAttributeExpression(expression, attributeType)
return contactFilterBuildClause(attribute, expression, expressionArgs, operator, contactFilterCustomAttributeValues(values, attributeType), allowedOperators, true)
}
}
func contactFilterComparisonOperator(operator string) string {
switch operator {
case "is_greater_than":
return ">"
case "is_less_than", "days_before":
return "<"
default:
return ""
}
}
func contactFilterCustomAttributeValues(values []string, attributeType string) []string {
switch strings.TrimSpace(attributeType) {
case "text", "link", "list", "":
lowered := make([]string, 0, len(values))
for _, value := range values {
lowered = append(lowered, strings.ToLower(value))
}
return lowered
default:
return values
}
}
func contactFilterCustomAttributeExpression(expression, attributeType string) string {
switch strings.TrimSpace(attributeType) {
case "text", "link", "list", "":
return "LOWER(" + expression + ")"
default:
return expression
}
}
func contactFilterNormalizeValues(attribute string, values []string) []string {
normalized := make([]string, 0, len(values))
for _, value := range values {
switch attribute {
case "phone_number":
trimmed := strings.TrimPrefix(value, "+")
if trimmed != "" {
normalized = append(normalized, "+"+trimmed)
}
case "name", "email", "identifier", "country_code", "city", "company_name":
normalized = append(normalized, strings.ToLower(value))
default:
normalized = append(normalized, value)
}
}
return normalized
}
func contactFilterCoerceValue(attribute string, value string) any {
if attribute != "blocked" {
return value
}
return value == "true" || value == "1"
}
func contactFilterOperatorAllowed(operator string, allowed []string) bool {
for _, value := range allowed {
if operator == value {
return true
}
}
return false
}
func contactFilterIsQueryOperator(value string) bool {
return value == "AND" || value == "OR"
}
func contactFilterStringValues(values []any) []string {
result := make([]string, 0, len(values))
for _, value := range values {
text := strings.TrimSpace(fmt.Sprintf("%v", value))
if text != "" {
result = append(result, text)
}
}
return result
}