feat(crm): complete contact label avatar gaps

This commit is contained in:
2026-06-05 02:53:24 +08:00
parent 5cf735076d
commit 7a033e28a0
16 changed files with 512 additions and 31 deletions
+83 -20
View File
@@ -39,29 +39,42 @@ func (r *ContactRepo) DB() *gorm.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) ([]model.Contact, int64, error) {
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
countDB := r.db.WithContext(ctx).Model(&model.Contact{}).Where("account_id = ?", accountID)
labelFilter := firstContactLabelFilter(labels)
base := r.db.WithContext(ctx).Model(&model.Contact{}).Where("contacts.account_id = ?", 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)
err := r.db.WithContext(ctx).Where("account_id = ?", accountID).
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) ([]model.Contact, int64, error) {
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("account_id = ?", accountID)
Where("contacts.account_id = ?", accountID)
labelFilter := firstContactLabelFilter(labels)
condition = applyContactLabelFilter(condition, accountID, labelFilter)
if query != "" {
if searchMode == search.SearchModeTrigram {
@@ -76,31 +89,73 @@ func (r *ContactRepo) Search(ctx context.Context, accountID uint, query string,
}
}
if err := condition.Count(&total).Error; err != nil {
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)
err := condition.Offset(offset).Limit(limit).Order(orderClause).
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)
}
// 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": "name ASC",
"email": "email ASC",
"created_at": "created_at DESC",
"last_activity_at": "last_activity_at DESC NULLS LAST, id DESC",
"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 "last_activity_at DESC NULLS LAST, id DESC"
return "contacts.last_activity_at DESC NULLS LAST, contacts.id DESC"
}
// FindByAccountAndID retrieves a contact scoped to an account.
@@ -221,7 +276,8 @@ func (r *ContactRepo) Filter(ctx context.Context, accountID uint, params Contact
var contacts []model.Contact
var total int64
q := r.db.WithContext(ctx).Model(&model.Contact{}).Where("account_id = ?", accountID)
q := r.db.WithContext(ctx).Model(&model.Contact{}).Where("contacts.account_id = ?", accountID)
usesDistinct := false
// Apply contact type filter
if params.ContactType != "" {
@@ -236,14 +292,13 @@ func (r *ContactRepo) Filter(ctx context.Context, accountID uint, params Contact
// 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 (contacts with conversations having matching labels)
// Apply labels filter (Chatwoot: contact label_list tagged_with any: true)
if params.Labels != "" {
labelList := strings.Split(params.Labels, ",")
for _, label := range labelList {
q = q.Joins("JOIN conversations ON conversations.contact_id = contacts.id AND conversations.labels LIKE ?", "%"+label+"%")
}
q = applyContactLabelFilter(q, accountID, strings.Split(params.Labels, ","))
usesDistinct = true
}
// Apply status filter (active = has recent activity, inactive = no recent activity)
@@ -260,12 +315,20 @@ func (r *ContactRepo) Filter(ctx context.Context, accountID uint, params Contact
}
// Count total matching records
if err := q.Count(&total).Error; err != nil {
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)
err := q.Offset(offset).Limit(limit).Order(orderClause).
queryDB := q
if usesDistinct {
queryDB = queryDB.Distinct("contacts.*")
}
err := queryDB.Offset(offset).Limit(limit).Order(orderClause).
Find(&contacts).Error
return contacts, total, err
}
+1
View File
@@ -151,6 +151,7 @@ func defaultTestModels() []interface{} {
&csat.CsatSurveyResponse{},
&model.Tag{},
&model.ConversationLabel{},
&model.ContactLabel{},
&model.PlatformApp{},
&model.Permissible{},
&model.AccessToken{},