Files
gochat/internal/repository/contact_repo.go
T

392 lines
14 KiB
Go

package repository
import (
"context"
"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 = 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)
}
// 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)
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
}
// 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)
usesDistinct := false
// 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
}