feat(crm): align contact company payloads

This commit is contained in:
2026-06-05 02:34:06 +08:00
parent c7053d975d
commit 3481597c6f
13 changed files with 606 additions and 186 deletions
+24 -23
View File
@@ -4,7 +4,6 @@ import (
"context"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/search"
@@ -21,6 +20,10 @@ func NewCompanyRepo(db *gorm.DB) *CompanyRepo {
return &CompanyRepo{db: db}
}
func (r *CompanyRepo) DB() *gorm.DB {
return r.db
}
// FindByID retrieves a company by primary key.
func (r *CompanyRepo) FindByID(ctx context.Context, id uint) (*model.Company, error) {
var company model.Company
@@ -111,22 +114,34 @@ func (r *CompanyRepo) ListContacts(ctx context.Context, companyID, accountID uin
var contacts []model.Contact
var total int64
// Count contacts in the join table that belong to the account
countDB := r.db.WithContext(ctx).Model(&model.Contact{}).
Joins("JOIN company_contacts ON company_contacts.contact_id = contacts.id").
Where("company_contacts.company_id = ? AND contacts.account_id = ?", companyID, accountID)
Where("company_id = ? AND account_id = ?", companyID, accountID)
if err := countDB.Count(&total).Error; err != nil {
return nil, 0, err
}
err := r.db.WithContext(ctx).
Joins("JOIN company_contacts ON company_contacts.contact_id = contacts.id").
Where("company_contacts.company_id = ? AND contacts.account_id = ?", companyID, accountID).
Offset(offset).Limit(limit).Order("contacts.id DESC").
Where("company_id = ? AND account_id = ?", companyID, accountID).
Offset(offset).Limit(limit).Order("name ASC, id ASC").
Find(&contacts).Error
return contacts, total, err
}
func (r *CompanyRepo) SearchAssignableContacts(ctx context.Context, companyID, accountID uint, query string, offset, limit int) ([]model.Contact, int64, error) {
var contacts []model.Contact
var total int64
likeQuery := "%" + query + "%"
condition := r.db.WithContext(ctx).Model(&model.Contact{}).
Where("account_id = ?", accountID).
Where("company_id IS NULL OR company_id != ?", companyID).
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)
if err := condition.Count(&total).Error; err != nil {
return nil, 0, err
}
err := condition.Offset(offset).Limit(limit).Order("name ASC, id ASC").Find(&contacts).Error
return contacts, total, err
}
// ListNotes retrieves notes for a company.
func (r *CompanyRepo) ListNotes(ctx context.Context, companyID uint, offset, limit int) ([]model.CompanyNote, int64, error) {
var notes []model.CompanyNote
@@ -154,26 +169,12 @@ func (r *CompanyRepo) DeleteNote(ctx context.Context, id uint) error {
return r.db.WithContext(ctx).Delete(&model.CompanyNote{}, id).Error
}
// AddContact adds a contact to a company via the company_contacts join table.
// Uses GORM's clause.OnConflict for dialect-aware upsert (works on both PG and SQLite).
func (r *CompanyRepo) AddContact(ctx context.Context, companyID, contactID uint) error {
association := struct {
CompanyID uint `gorm:"primaryKey"`
ContactID uint `gorm:"primaryKey"`
}{
CompanyID: companyID,
ContactID: contactID,
}
return r.db.WithContext(ctx).Table("company_contacts").
Clauses(clause.OnConflict{DoNothing: true}).
Create(&association).Error
return r.db.WithContext(ctx).Model(&model.Contact{}).Where("id = ?", contactID).Update("company_id", companyID).Error
}
// RemoveContact removes a contact from a company via the company_contacts join table.
func (r *CompanyRepo) RemoveContact(ctx context.Context, companyID, contactID uint) error {
return r.db.WithContext(ctx).Table("company_contacts").
Where("company_id = ? AND contact_id = ?", companyID, contactID).
Delete(nil).Error
return r.db.WithContext(ctx).Model(&model.Contact{}).Where("id = ? AND company_id = ?", contactID, companyID).Update("company_id", nil).Error
}
// resolveCompanySort maps a sort parameter to a SQL ORDER BY clause.
+14 -17
View File
@@ -265,6 +265,7 @@ func TestCompanyRepo_CreateNote(t *testing.T) {
assert.NoError(t, err)
assert.NotZero(t, note.ID)
}
// ========== DeleteNote Tests ==========
func TestCompanyRepo_DeleteNote(t *testing.T) {
@@ -314,10 +315,10 @@ func TestCompanyRepo_AddContact(t *testing.T) {
err := repo.AddContact(context.Background(), company.ID, contact.ID)
assert.NoError(t, err)
// Verify the association exists in the join table
var count int64
db.Table("company_contacts").Where("company_id = ? AND contact_id = ?", company.ID, contact.ID).Count(&count)
assert.Equal(t, int64(1), count)
var updated model.Contact
require.NoError(t, db.First(&updated, contact.ID).Error)
require.NotNil(t, updated.CompanyID)
assert.Equal(t, company.ID, *updated.CompanyID)
}
func TestCompanyRepo_AddContact_Duplicate(t *testing.T) {
@@ -335,14 +336,14 @@ func TestCompanyRepo_AddContact_Duplicate(t *testing.T) {
err := repo.AddContact(context.Background(), company.ID, contact.ID)
assert.NoError(t, err)
// Adding duplicate should succeed (ON CONFLICT DO NOTHING)
// Assigning the same company twice should stay idempotent.
err = repo.AddContact(context.Background(), company.ID, contact.ID)
assert.NoError(t, err)
// Verify only one association exists
var count int64
db.Table("company_contacts").Where("company_id = ? AND contact_id = ?", company.ID, contact.ID).Count(&count)
assert.Equal(t, int64(1), count)
var updated model.Contact
require.NoError(t, db.First(&updated, contact.ID).Error)
require.NotNil(t, updated.CompanyID)
assert.Equal(t, company.ID, *updated.CompanyID)
}
// ========== RemoveContact Tests ==========
@@ -355,19 +356,15 @@ func TestCompanyRepo_RemoveContact(t *testing.T) {
require.NoError(t, db.Create(account).Error)
company := createTestCompany(t, db, account.ID, "RemoveContactCorp", "removecontact.example.com")
contact := &model.Contact{AccountID: account.ID, Name: "Remove Contact", Email: "remove@example.com"}
contact := &model.Contact{AccountID: account.ID, Name: "Remove Contact", Email: "remove@example.com", CompanyID: &company.ID}
require.NoError(t, db.Create(contact).Error)
// Create association via GORM many2many
require.NoError(t, db.Model(&company).Association("Contacts").Append(contact))
err := repo.RemoveContact(context.Background(), company.ID, contact.ID)
assert.NoError(t, err)
// Verify the association is gone
var count int64
db.Table("company_contacts").Where("company_id = ? AND contact_id = ?", company.ID, contact.ID).Count(&count)
assert.Equal(t, int64(0), count)
var updated model.Contact
require.NoError(t, db.First(&updated, contact.ID).Error)
assert.Nil(t, updated.CompanyID)
}
func TestCompanyRepo_RemoveContact_NotAssociated(t *testing.T) {
+7 -3
View File
@@ -33,6 +33,10 @@ func (r *ContactRepo) FindByID(ctx context.Context, id uint) (*model.Contact, er
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) ([]model.Contact, int64, error) {
@@ -62,12 +66,12 @@ func (r *ContactRepo) Search(ctx context.Context, accountID uint, query string,
if query != "" {
if searchMode == search.SearchModeTrigram {
// pg_trgm fuzzy match on contact fields
condition = condition.Where("name % ? OR email % ? OR phone ILIKE ? OR identifier % ?",
condition = condition.Where("name % ? OR email % ? OR phone_number ILIKE ? OR identifier % ?",
query, query, query, query)
} else {
// ILIKE substring match (default)
// Case-insensitive substring match that works on PostgreSQL and SQLite tests.
likeQuery := "%" + query + "%"
condition = condition.Where("name ILIKE ? OR email ILIKE ? OR phone ILIKE ? OR identifier ILIKE ?",
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)
}
}