845 lines
30 KiB
Go
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
|
|
}
|