392 lines
14 KiB
Go
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
|
|
}
|