package service import ( "bytes" "context" "encoding/csv" "encoding/json" "errors" "fmt" "io" "strconv" "strings" "time" "gorm.io/datatypes" "gorm.io/gorm" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/search" applogger "github.com/gochat/gochat/pkg/logger" pkgvalidator "github.com/gochat/gochat/pkg/validator" ) // ContactService implements business logic for Contact operations. // Reference: Chatwoot app/controllers/api/v1/contacts_controller.rb type ContactService struct { repo *repository.ContactRepo contactInboxSvc *ContactInboxService noteRepo *repository.NoteRepo searchIndexer SearchIndexer searchReader ContactSearchReader exportMailer ContactExportMailer } // NewContactService creates a new Contact service. func NewContactService(repo *repository.ContactRepo, contactInboxSvc *ContactInboxService, noteRepo *repository.NoteRepo) *ContactService { return &ContactService{repo: repo, contactInboxSvc: contactInboxSvc, noteRepo: noteRepo} } func (s *ContactService) SetSearchIndexer(indexer SearchIndexer) { s.searchIndexer = indexer } func (s *ContactService) SetSearchReader(reader ContactSearchReader) { s.searchReader = reader } func (s *ContactService) SetContactExportMailer(mailer ContactExportMailer) { s.exportMailer = mailer } func (s *ContactService) indexContact(ctx context.Context, contact *model.Contact) { if s.searchIndexer != nil { logSearchIndexError("contact", contact.ID, s.searchIndexer.IndexContact(ctx, contact)) } } func (s *ContactService) deleteContactIndex(ctx context.Context, accountID uint, id uint) { if s.searchIndexer != nil { logSearchIndexError("contact", id, s.searchIndexer.DeleteContact(ctx, accountID, id)) } } // Ready reports whether the service has the dependencies required for DB-backed operations. func (s *ContactService) Ready() bool { return s != nil && s.repo != nil } func (s *ContactService) DB() *gorm.DB { if s == nil || s.repo == nil { return nil } return s.repo.DB() } // ListByAccount retrieves all contacts for an account with optional sort. func (s *ContactService) ListByAccount(ctx context.Context, accountID uint, offset, limit int, sort string, labels ...[]string) ([]model.Contact, int64, error) { return s.repo.FindByAccount(ctx, accountID, offset, limit, sort, labels...) } // Search searches contacts by name, email, phone, or identifier with optional sort. func (s *ContactService) Search(ctx context.Context, accountID uint, query string, offset, limit int, sort string, searchMode search.SearchMode, labels ...[]string) ([]model.Contact, int64, error) { if query == "" { return s.repo.FindByAccount(ctx, accountID, offset, limit, sort, labels...) } if s.searchReader != nil { filter := serviceSearchFilter(offset, limit, sort, searchMode, search.ResultTypeContact) filter.Labels = firstServiceContactLabelFilter(labels) results, total, err := s.searchReader.SearchContacts(ctx, accountID, query, filter) if err != nil { return nil, 0, err } contacts, err := s.contactsFromSearchResults(ctx, accountID, results) if err != nil { return nil, 0, err } return contacts, total, nil } return s.repo.Search(ctx, accountID, query, offset, limit, sort, searchMode, labels...) } func (s *ContactService) contactsFromSearchResults(ctx context.Context, accountID uint, results []search.SearchResult) ([]model.Contact, error) { contacts := make([]model.Contact, 0, len(results)) for _, result := range results { if result.ID == 0 || result.AccountID != accountID { continue } contact, err := s.repo.FindByAccountAndID(ctx, accountID, result.ID) if err != nil { continue } contacts = append(contacts, *contact) } return contacts, nil } func firstServiceContactLabelFilter(filters [][]string) []string { if len(filters) == 0 { return nil } return normalizeContactServiceLabels(filters[0]) } // GetByID retrieves a single contact. func (s *ContactService) GetByID(ctx context.Context, id uint) (*model.Contact, error) { return s.repo.FindByID(ctx, id) } // GetByAccountAndID retrieves a contact scoped to an account. func (s *ContactService) GetByAccountAndID(ctx context.Context, accountID, id uint) (*model.Contact, error) { return s.repo.FindByAccountAndID(ctx, accountID, id) } // ListContactInboxes retrieves all contact_inboxes for a contact. func (s *ContactService) ListContactInboxes(ctx context.Context, contactID uint) ([]model.ContactInbox, error) { return s.contactInboxSvc.ListByContact(ctx, contactID) } // CreateContactRequest is the DTO for creating a contact. // Reference: Chatwoot app/controllers/api/v1/contacts_controller.rb#create // When inbox_id is provided, a ContactInbox record is auto-created (Chatwoot pattern). type CreateContactRequest struct { Name string `json:"name" validate:"required,min=1"` Email string `json:"email,omitempty" validate:"omitempty,email"` Phone string `json:"phone,omitempty"` Identifier string `json:"identifier,omitempty"` AvatarURL string `json:"avatar_url,omitempty"` InboxID *uint `json:"inbox_id,omitempty"` SourceID string `json:"source_id,omitempty"` AdditionalAttributes *model.JSONMap `json:"additional_attributes,omitempty"` CustomAttributes *model.JSONMap `json:"custom_attributes,omitempty"` ContactType string `json:"contact_type,omitempty"` MiddleName string `json:"middle_name,omitempty"` LastName string `json:"last_name,omitempty"` CountryCode string `json:"country_code,omitempty"` Location string `json:"location,omitempty"` CompanyID *uint `json:"company_id,omitempty"` } // Create creates a new contact and optionally auto-creates a ContactInbox when inbox_id is provided. // Reference: Chatwoot contacts_controller#create — auto-creates ContactInbox for channel source. func (s *ContactService) Create(ctx context.Context, accountID uint, req CreateContactRequest) (*model.Contact, error) { if err := pkgvalidator.ValidateStruct(req); err != nil { return nil, err } contact := &model.Contact{ AccountID: accountID, Name: req.Name, Email: req.Email, PhoneNumber: req.Phone, Identifier: req.Identifier, AvatarURL: req.AvatarURL, MiddleName: req.MiddleName, LastName: req.LastName, CountryCode: req.CountryCode, Location: req.Location, ContactType: req.ContactType, SourceID: req.SourceID, CompanyID: req.CompanyID, } if req.AdditionalAttributes != nil { contact.AdditionalAttributes = mergeContactJSON(contact.AdditionalAttributes, model.ToDatatypesJSON(req.AdditionalAttributes)) } if req.CustomAttributes != nil { contact.CustomAttributes = mergeContactJSON(contact.CustomAttributes, model.ToDatatypesJSON(req.CustomAttributes)) } if err := s.repo.Create(ctx, contact); err != nil { applogger.L().Errorf("Failed to create contact: %v", err) return nil, err } s.indexContact(ctx, contact) // Auto-create ContactInbox when inbox_id is provided (Chatwoot pattern) if req.InboxID != nil && *req.InboxID > 0 { sourceID := req.SourceID if sourceID == "" { sourceID = contact.Email // fallback: use email as source_id } ciReq := CreateContactInboxRequest{ ContactID: contact.ID, InboxID: *req.InboxID, SourceID: sourceID, } if _, err := s.contactInboxSvc.Create(ctx, ciReq); err != nil { applogger.L().Errorf("Failed to auto-create ContactInbox for contact %d: %v", contact.ID, err) // Non-blocking: contact is created, but ContactInbox creation failed } } return contact, nil } // UpdateContactRequest is the DTO for updating a contact. type UpdateContactRequest struct { Name string `json:"name,omitempty" validate:"omitempty,min=1"` Email string `json:"email,omitempty" validate:"omitempty,email"` Phone string `json:"phone,omitempty"` Identifier string `json:"identifier,omitempty"` AvatarURL string `json:"avatar_url,omitempty"` MiddleName string `json:"middle_name,omitempty"` LastName string `json:"last_name,omitempty"` CountryCode string `json:"country_code,omitempty"` Location string `json:"location,omitempty"` ContactType string `json:"contact_type,omitempty"` AdditionalAttributes *model.JSONMap `json:"additional_attributes,omitempty"` CustomAttributes *model.JSONMap `json:"custom_attributes,omitempty"` CompanyID *uint `json:"company_id,omitempty"` } // Update modifies an existing contact. func (s *ContactService) Update(ctx context.Context, accountID, id uint, req UpdateContactRequest) (*model.Contact, error) { if err := pkgvalidator.ValidateStruct(req); err != nil { return nil, err } contact, err := s.repo.FindByAccountAndID(ctx, accountID, id) if err != nil { return nil, err } if req.Name != "" { contact.Name = req.Name } if req.Email != "" { contact.Email = req.Email } if req.Phone != "" { contact.PhoneNumber = req.Phone } if req.Identifier != "" { contact.Identifier = req.Identifier } if req.AvatarURL != "" { contact.AvatarURL = req.AvatarURL } if req.MiddleName != "" { contact.MiddleName = req.MiddleName } if req.LastName != "" { contact.LastName = req.LastName } if req.CountryCode != "" { contact.CountryCode = req.CountryCode } if req.Location != "" { contact.Location = req.Location } if req.ContactType != "" { contact.ContactType = req.ContactType } if req.AdditionalAttributes != nil { contact.AdditionalAttributes = model.ToDatatypesJSON(req.AdditionalAttributes) } if req.CustomAttributes != nil { contact.CustomAttributes = model.ToDatatypesJSON(req.CustomAttributes) } if req.CompanyID != nil { contact.CompanyID = req.CompanyID } if err := s.repo.Update(ctx, contact); err != nil { return nil, err } s.indexContact(ctx, contact) return contact, nil } // Delete soft-deletes a contact. func (s *ContactService) Delete(ctx context.Context, accountID, id uint) error { contact, err := s.repo.FindByAccountAndID(ctx, accountID, id) if err != nil { return err } if err := s.repo.Delete(ctx, contact.ID); err != nil { return err } s.deleteContactIndex(ctx, accountID, contact.ID) return nil } // CreateNoteRequest is the DTO for creating a contact note. type CreateNoteRequest struct { Content string `json:"content" validate:"required,min=1"` } // ListNotes retrieves notes for a contact. // Reference: Chatwoot app/controllers/api/v1/contacts/notes_controller.rb #index func (s *ContactService) ListNotes(ctx context.Context, accountID, contactID uint) ([]model.Note, error) { // Verify contact belongs to account _, err := s.repo.FindByAccountAndID(ctx, accountID, contactID) if err != nil { return nil, errors.New("contact not found") } return s.noteRepo.FindByContact(ctx, accountID, contactID) } // CreateNote creates a note for a contact. // Reference: Chatwoot app/controllers/api/v1/contacts/notes_controller.rb #create func (s *ContactService) CreateNote(ctx context.Context, accountID, contactID, userID uint, req CreateNoteRequest) (*model.Note, error) { if err := pkgvalidator.ValidateStruct(req); err != nil { return nil, err } // Verify contact belongs to account _, err := s.repo.FindByAccountAndID(ctx, accountID, contactID) if err != nil { return nil, errors.New("contact not found") } note := &model.Note{ Content: req.Content, AccountID: accountID, ContactID: contactID, UserID: &userID, } if err := s.noteRepo.Create(ctx, note); err != nil { return nil, err } if s.repo != nil && s.repo.DB() != nil { _ = s.repo.DB().WithContext(ctx).Preload("User").First(note, note.ID).Error } return note, nil } // GetNote retrieves a single note scoped to account and contact. func (s *ContactService) GetNote(ctx context.Context, accountID, contactID, noteID uint) (*model.Note, error) { if s == nil || s.noteRepo == nil { return nil, errors.New("contact service not ready") } return s.noteRepo.GetByIDContext(ctx, accountID, contactID, noteID) } // UpdateNote updates a note scoped to account and contact. func (s *ContactService) UpdateNote(ctx context.Context, accountID, contactID, noteID uint, req CreateNoteRequest) (*model.Note, error) { if err := pkgvalidator.ValidateStruct(req); err != nil { return nil, err } note, err := s.noteRepo.GetByIDContext(ctx, accountID, contactID, noteID) if err != nil { return nil, err } note.Content = req.Content return s.noteRepo.Update(note) } // DeleteNote removes a note scoped to account and contact. func (s *ContactService) DeleteNote(ctx context.Context, accountID, contactID, noteID uint) error { if s == nil || s.noteRepo == nil { return errors.New("contact service not ready") } if _, err := s.noteRepo.GetByIDContext(ctx, accountID, contactID, noteID); err != nil { return err } return s.noteRepo.DeleteContext(ctx, accountID, contactID, noteID) } // ListActive retrieves contacts with recent activity for an account. // GET /api/v1/accounts/:id/contacts/active // Reference: Chatwoot contacts#active func (s *ContactService) ListActive(ctx context.Context, accountID uint, offset, limit int, sort string) ([]model.Contact, int64, error) { return s.repo.FindActive(ctx, accountID, offset, limit, sort) } // ExportCSV writes all contacts for an account as CSV. // GET /api/v1/accounts/:id/contacts/export // Reference: Chatwoot contacts#export func (s *ContactService) ExportCSV(ctx context.Context, accountID uint, w io.Writer) error { csvData, _, err := s.GenerateContactExportCSV(ctx, accountID, ContactExportRequest{}) if err != nil { return err } _, err = w.Write(csvData) return nil } // ImportCSVResult holds the result of a CSV import operation. type ImportCSVResult struct { Imported int `json:"imported"` Skipped int `json:"skipped"` Failed int `json:"failed"` } type ContactExportRequest struct { ColumnNames []string `json:"column_names"` Payload []ContactExportFilterCondition `json:"payload"` Label string `json:"label"` } type ContactExportFilterCondition struct { AttributeKey string `json:"attribute_key"` FilterType string `json:"filter_type"` Operator string `json:"operator"` Values []any `json:"values"` } func (s *ContactService) ExportContacts(ctx context.Context, accountID, userID uint, req ContactExportRequest) (*model.ContactExport, error) { if !s.Ready() { return nil, errors.New("contact service not ready") } var account model.Account if err := s.repo.DB().WithContext(ctx).First(&account, accountID).Error; err != nil { return nil, err } var userIDPtr *uint if userID != 0 { userIDPtr = &userID } columnsJSON, _ := json.Marshal(req.ColumnNames) filterJSON, _ := json.Marshal(map[string]any{"payload": req.Payload, "label": req.Label}) export := &model.ContactExport{ AccountID: accountID, UserID: userIDPtr, Status: string(model.DataImportStatusPending), FileName: contactExportFilename(account), ContentType: "text/csv", ColumnNames: columnsJSON, FilterParams: filterJSON, } if err := s.repo.DB().WithContext(ctx).Create(export).Error; err != nil { return nil, err } if err := s.repo.DB().WithContext(ctx).Model(export).Update("status", string(model.DataImportStatusProcessing)).Error; err != nil { return nil, err } csvData, rowCount, err := s.GenerateContactExportCSV(ctx, accountID, req) if err != nil { s.repo.DB().WithContext(ctx).Model(export).Updates(map[string]any{ "status": string(model.DataImportStatusFailed), "error": err.Error(), }) return export, err } completedAt := time.Now() export.FileURL = fmt.Sprintf("/api/v1/accounts/%d/contacts/export/%d/download", accountID, export.ID) updates := map[string]any{ "status": string(model.DataImportStatusCompleted), "csv_data": csvData, "row_count": rowCount, "file_url": export.FileURL, "completed_at": completedAt, } if err := s.repo.DB().WithContext(ctx).Model(export).Updates(updates).Error; err != nil { return nil, err } if err := s.repo.DB().WithContext(ctx).First(export, export.ID).Error; err != nil { return nil, err } if err := s.createContactExportNotification(ctx, export); err != nil { applogger.L().Warnf("contact export notification failed: %v", err) } if err := s.sendContactExportEmail(ctx, &account, export); err != nil { applogger.L().Warnf("contact export email failed: %v", err) } return export, nil } func (s *ContactService) sendContactExportEmail(ctx context.Context, account *model.Account, export *model.ContactExport) error { if s.exportMailer == nil || export == nil || export.UserID == nil || *export.UserID == 0 { return nil } var user model.User if err := s.repo.DB().WithContext(ctx).Where("id = ?", *export.UserID).First(&user).Error; err != nil { return err } return s.exportMailer.SendContactExportComplete(ctx, account, &user, export) } func (s *ContactService) GenerateContactExportCSV(ctx context.Context, accountID uint, req ContactExportRequest) ([]byte, int, error) { params := contactExportFilterParams(req) contacts, err := s.repo.FindForExport(ctx, accountID, params) if err != nil { return nil, 0, fmt.Errorf("failed to fetch contacts for export: %w", err) } headers := validContactExportHeaders(req.ColumnNames) labelsByContactID := map[uint][]string{} if containsString(headers, "labels") { ids := make([]uint, 0, len(contacts)) for _, contact := range contacts { ids = append(ids, contact.ID) } labelsByContactID, err = s.repo.ContactLabelsByContactIDs(ctx, accountID, ids) if err != nil { return nil, 0, fmt.Errorf("failed to fetch contact labels for export: %w", err) } } var body bytes.Buffer body.Write([]byte{0xEF, 0xBB, 0xBF}) csvWriter := csv.NewWriter(&body) if err := csvWriter.Write(headers); err != nil { return nil, 0, fmt.Errorf("failed to write CSV header: %w", err) } for _, contact := range contacts { row := make([]string, 0, len(headers)) for _, header := range headers { row = append(row, contactExportValue(contact, header, labelsByContactID[contact.ID])) } if err := csvWriter.Write(row); err != nil { return nil, 0, fmt.Errorf("failed to write CSV row: %w", err) } } csvWriter.Flush() if err := csvWriter.Error(); err != nil { return nil, 0, fmt.Errorf("CSV flush error: %w", err) } return body.Bytes(), len(contacts), nil } func (s *ContactService) GetContactExport(ctx context.Context, accountID, exportID uint) (*model.ContactExport, error) { var export model.ContactExport err := s.repo.DB().WithContext(ctx).Where("account_id = ? AND id = ?", accountID, exportID).First(&export).Error if err != nil { return nil, err } return &export, nil } func contactExportFilename(account model.Account) string { name := strings.TrimSpace(account.Name) if name == "" { name = "account" } name = strings.NewReplacer("/", "_", "\\", "_", " ", "_").Replace(name) return fmt.Sprintf("%s_%d_contacts.csv", name, account.ID) } func contactExportFilterParams(req ContactExportRequest) repository.ContactFilterParams { params := repository.ContactFilterParams{} if strings.TrimSpace(req.Label) != "" { params.Labels = strings.TrimSpace(req.Label) } for _, condition := range req.Payload { if len(condition.Values) == 0 { continue } value := strings.TrimSpace(contactExportFilterValue(condition.Values[0])) switch strings.TrimSpace(condition.AttributeKey) { case "contact_type": params.ContactType = value case "source_id", "contact_source": params.ContactSource = value case "status": params.Status = value case "labels", "label_list": params.Labels = strings.Join(contactExportFilterValues(condition.Values), ",") case "inbox_id": if n, err := strconv.ParseUint(value, 10, 32); err == nil && n != 0 { inboxID := uint(n) params.InboxID = &inboxID } case "updated_within": if n, err := strconv.Atoi(value); err == nil && n > 0 { params.UpdatedWithin = &n } } } return params } func contactExportFilterValue(value any) string { switch v := value.(type) { case string: return v case float64: return strconv.FormatFloat(v, 'f', -1, 64) case int: return strconv.Itoa(v) case uint: return strconv.FormatUint(uint64(v), 10) case json.Number: return v.String() default: return fmt.Sprint(v) } } func contactExportFilterValues(values []any) []string { result := make([]string, 0, len(values)) for _, value := range values { text := strings.TrimSpace(contactExportFilterValue(value)) if text != "" { result = append(result, text) } } return result } func validContactExportHeaders(columnNames []string) []string { requested := columnNames if len(requested) == 0 { requested = []string{"id", "name", "email", "phone_number", "labels"} } allowed := map[string]struct{}{ "id": {}, "name": {}, "middle_name": {}, "last_name": {}, "email": {}, "phone_number": {}, "identifier": {}, "country_code": {}, "location": {}, "contact_type": {}, "blocked": {}, "source_id": {}, "company_id": {}, "last_activity_at": {}, "created_at": {}, "updated_at": {}, "labels": {}, } seen := map[string]struct{}{} headers := make([]string, 0, len(requested)) for _, header := range requested { header = strings.TrimSpace(header) if header == "" { continue } if _, ok := allowed[header]; !ok { continue } if _, ok := seen[header]; ok { continue } seen[header] = struct{}{} headers = append(headers, header) } return headers } func contactExportValue(contact model.Contact, header string, labels []string) string { switch header { case "id": return strconv.FormatUint(uint64(contact.ID), 10) case "name": return contact.Name case "middle_name": return contact.MiddleName case "last_name": return contact.LastName case "email": return contact.Email case "phone_number": return contact.PhoneNumber case "identifier": return contact.Identifier case "country_code": return contact.CountryCode case "location": return contact.Location case "contact_type": return contact.ContactType case "blocked": return strconv.FormatBool(contact.Blocked) case "source_id": return contact.SourceID case "company_id": if contact.CompanyID == nil { return "" } return strconv.FormatUint(uint64(*contact.CompanyID), 10) case "last_activity_at": if contact.LastActivityAt == nil { return "" } return strconv.FormatInt(*contact.LastActivityAt, 10) case "created_at": return contact.CreatedAt.Format(time.RFC3339) case "updated_at": return contact.UpdatedAt.Format(time.RFC3339) case "labels": return strings.Join(labels, ",") default: return "" } } func containsString(values []string, needle string) bool { for _, value := range values { if value == needle { return true } } return false } func (s *ContactService) createContactExportNotification(ctx context.Context, export *model.ContactExport) error { if export.UserID == nil || *export.UserID == 0 { return nil } attrs, _ := json.Marshal(map[string]any{ "file_url": export.FileURL, "file_name": export.FileName, "row_count": export.RowCount, }) notification := &model.Notification{ AccountID: &export.AccountID, UserID: *export.UserID, NotificationType: "contacts_export_complete", PrimaryActorType: "ContactExport", PrimaryActorID: export.ID, EmailEnabled: true, AdditionalAttributes: attrs, } return s.repo.DB().WithContext(ctx).Create(notification).Error } func (s *ContactService) ImportContacts(ctx context.Context, accountID, userID uint, r io.Reader) (*model.DataImport, error) { if !s.Ready() { return nil, errors.New("contact service not ready") } var userIDPtr *uint if userID != 0 { userIDPtr = &userID } dataImport := &model.DataImport{AccountID: accountID, UserID: userIDPtr, DataType: "contacts", Status: string(model.DataImportStatusPending)} if err := s.repo.DB().WithContext(ctx).Create(dataImport).Error; err != nil { return nil, err } if err := s.repo.DB().WithContext(ctx).Model(dataImport).Update("status", string(model.DataImportStatusProcessing)).Error; err != nil { return nil, err } result, err := s.ImportCSV(ctx, accountID, r) if err != nil { s.repo.DB().WithContext(ctx).Model(dataImport).Updates(map[string]any{ "status": string(model.DataImportStatusFailed), "processing_errors": err.Error(), }) return dataImport, err } updates := map[string]any{ "status": string(model.DataImportStatusCompleted), "processed_records": result.Imported, "failed_records": result.Failed, "total_records": result.Imported + result.Skipped + result.Failed, } if err := s.repo.DB().WithContext(ctx).Model(dataImport).Updates(updates).Error; err != nil { return nil, err } if err := s.repo.DB().WithContext(ctx).First(dataImport, dataImport.ID).Error; err != nil { return nil, err } return dataImport, nil } // ImportCSV reads contacts from a CSV reader and creates them. // POST /api/v1/accounts/:id/contacts/import // Reference: Chatwoot contacts#import func (s *ContactService) ImportCSV(ctx context.Context, accountID uint, r io.Reader) (*ImportCSVResult, error) { csvReader := csv.NewReader(r) // Read header row header, err := csvReader.Read() if err != nil { return nil, fmt.Errorf("failed to read CSV header: %w", err) } // Build column index map colIndex := make(map[string]int) for i, col := range header { colIndex[col] = i } result := &ImportCSVResult{} for { row, err := csvReader.Read() if err == io.EOF { break } if err != nil { result.Failed++ continue } contact := &model.Contact{AccountID: accountID} customAttributes := map[string]any{} var labels []string if idx, ok := colIndex["name"]; ok && idx < len(row) { contact.Name = row[idx] } if idx, ok := colIndex["email"]; ok && idx < len(row) { contact.Email = row[idx] } if idx, ok := colIndex["phone_number"]; ok && idx < len(row) { contact.PhoneNumber = formatImportPhone(row[idx]) } if idx, ok := colIndex["identifier"]; ok && idx < len(row) { contact.Identifier = row[idx] } if idx, ok := colIndex["country_code"]; ok && idx < len(row) { contact.CountryCode = row[idx] } if idx, ok := colIndex["location"]; ok && idx < len(row) { contact.Location = row[idx] } if idx, ok := colIndex["city"]; ok && idx < len(row) && row[idx] != "" { contact.Location = row[idx] } if idx, ok := colIndex["company_name"]; ok && idx < len(row) && row[idx] != "" { customAttributes["company_name"] = row[idx] } if idx, ok := colIndex["contact_type"]; ok && idx < len(row) { contact.ContactType = row[idx] } if idx, ok := colIndex["labels"]; ok && idx < len(row) { labels = splitImportLabels(row[idx]) } labels, invalidLabels, err := s.resolveApprovedImportLabels(ctx, accountID, labels) if err != nil { return nil, err } if len(invalidLabels) > 0 { applogger.L().Warnf("Skipping imported contact row with unknown labels: %s", strings.Join(invalidLabels, ", ")) result.Failed++ continue } known := map[string]struct{}{"name": {}, "email": {}, "phone_number": {}, "identifier": {}, "country_code": {}, "location": {}, "city": {}, "company_name": {}, "contact_type": {}, "labels": {}} for i, col := range header { col = strings.TrimSpace(col) if col == "" || i >= len(row) { continue } if _, ok := known[col]; ok || row[i] == "" { continue } customAttributes[col] = row[i] } existing := s.findImportContact(ctx, accountID, contact) if existing != nil { mergeImportContact(existing, contact, customAttributes) if err := s.repo.Update(ctx, existing); err != nil { applogger.L().Errorf("Failed to update imported contact row: %v", err) result.Failed++ continue } if err := s.updateImportedLabels(ctx, accountID, existing.ID, labels); err != nil { applogger.L().Errorf("Failed to update imported contact labels: %v", err) result.Failed++ continue } result.Imported++ continue } contact.CustomAttributes = jsonFromMap(customAttributes) contact.AdditionalAttributes = datatypes.JSON("{}") if err := s.repo.Create(ctx, contact); err != nil { applogger.L().Errorf("Failed to import contact row: %v", err) result.Failed++ continue } if err := s.updateImportedLabels(ctx, accountID, contact.ID, labels); err != nil { applogger.L().Errorf("Failed to update imported contact labels: %v", err) result.Failed++ continue } result.Imported++ } return result, nil } func (s *ContactService) resolveApprovedImportLabels(ctx context.Context, accountID uint, labels []string) ([]string, []string, error) { if len(labels) == 0 { return nil, nil, nil } approved := map[string]string{} var tags []model.Tag if err := s.repo.DB().WithContext(ctx).Where("account_id = ?", accountID).Find(&tags).Error; err != nil { return nil, nil, err } for _, tag := range tags { approved[strings.ToLower(strings.TrimSpace(tag.Name))] = tag.Name } seen := map[string]struct{}{} resolved := make([]string, 0, len(labels)) invalid := make([]string, 0) for _, label := range labels { label = strings.TrimSpace(label) if label == "" { continue } key := strings.ToLower(label) canonical, ok := approved[key] if !ok { if _, exists := seen["invalid:"+key]; !exists { invalid = append(invalid, key) seen["invalid:"+key] = struct{}{} } continue } if _, exists := seen[key]; exists { continue } seen[key] = struct{}{} resolved = append(resolved, canonical) } return resolved, invalid, nil } func (s *ContactService) findImportContact(ctx context.Context, accountID uint, contact *model.Contact) *model.Contact { if contact.Identifier != "" { if existing, err := s.repo.FindByIdentifier(ctx, accountID, contact.Identifier); err == nil { return existing } } if contact.Email != "" { if existing, err := s.repo.FindByEmail(ctx, accountID, contact.Email); err == nil { return existing } } if contact.PhoneNumber != "" { var existing model.Contact if err := s.repo.DB().WithContext(ctx).Where("account_id = ? AND phone_number = ?", accountID, contact.PhoneNumber).First(&existing).Error; err == nil { return &existing } } return nil } func mergeImportContact(existing *model.Contact, incoming *model.Contact, customAttributes map[string]any) { if incoming.Identifier != "" { existing.Identifier = incoming.Identifier } if incoming.Email != "" { existing.Email = incoming.Email } if incoming.PhoneNumber != "" { existing.PhoneNumber = incoming.PhoneNumber } if incoming.Name != "" { existing.Name = incoming.Name } if incoming.CountryCode != "" { existing.CountryCode = incoming.CountryCode } if incoming.Location != "" { existing.Location = incoming.Location } if incoming.ContactType != "" { existing.ContactType = incoming.ContactType } merged := map[string]any{} if len(existing.CustomAttributes) > 0 { _ = json.Unmarshal(existing.CustomAttributes, &merged) } for key, value := range customAttributes { merged[key] = value } existing.CustomAttributes = jsonFromMap(merged) } func jsonFromMap(values map[string]any) datatypes.JSON { if len(values) == 0 { return datatypes.JSON("{}") } bytes, _ := json.Marshal(values) return datatypes.JSON(bytes) } func formatImportPhone(phone string) string { phone = strings.TrimSpace(phone) if phone == "" || strings.HasPrefix(phone, "+") { return phone } return "+" + phone } func splitImportLabels(raw string) []string { parts := strings.Split(raw, ",") labels := make([]string, 0, len(parts)) for _, part := range parts { part = strings.TrimSpace(part) if part != "" { labels = append(labels, part) } } return labels } func (s *ContactService) updateImportedLabels(ctx context.Context, accountID, contactID uint, labels []string) error { if len(labels) == 0 { return nil } _, err := s.UpdateLabels(ctx, accountID, contactID, labels) return err } // Filter retrieves contacts matching advanced filter criteria. // POST /api/v1/accounts/:id/contacts/filter // Reference: Chatwoot ContactFilterService#perform — filters by contact_type, source, // assignee, inbox, labels, status, and applies sort order with pagination. func (s *ContactService) Filter(ctx context.Context, accountID uint, params repository.ContactFilterParams, offset, limit int) ([]model.Contact, int64, error) { return s.repo.Filter(ctx, accountID, params, offset, limit) } // DeleteCustomAttributes removes all custom attributes from a contact. // DELETE /api/v1/accounts/:id/contacts/:contact_id/custom_attributes // Reference: Chatwoot contacts#destroy_custom_attributes func (s *ContactService) DeleteCustomAttributes(ctx context.Context, accountID, contactID uint) error { // Verify contact belongs to account contact, err := s.repo.FindByAccountAndID(ctx, accountID, contactID) if err != nil { return errors.New("contact not found") } return s.repo.DeleteCustomAttributes(ctx, contact.ID) } func (s *ContactService) DestroyCustomAttributes(ctx context.Context, accountID, contactID uint, keys []string) (*model.Contact, error) { contact, err := s.repo.FindByAccountAndID(ctx, accountID, contactID) if err != nil { return nil, errors.New("contact not found") } attrs := map[string]any{} if len(contact.CustomAttributes) > 0 { _ = json.Unmarshal(contact.CustomAttributes, &attrs) } for _, key := range keys { delete(attrs, key) } bytes, _ := json.Marshal(attrs) contact.CustomAttributes = datatypes.JSON(bytes) if err := s.repo.Update(ctx, contact); err != nil { return nil, err } s.indexContact(ctx, contact) return contact, nil } func (s *ContactService) DeleteAvatar(ctx context.Context, accountID, contactID uint) (*model.Contact, error) { contact, err := s.repo.FindByAccountAndID(ctx, accountID, contactID) if err != nil { return nil, errors.New("contact not found") } contact.AvatarURL = "" if err := s.repo.Update(ctx, contact); err != nil { return nil, err } s.indexContact(ctx, contact) return contact, nil } func (s *ContactService) GetLabels(ctx context.Context, accountID, contactID uint) ([]string, error) { if _, err := s.repo.FindByAccountAndID(ctx, accountID, contactID); err != nil { return nil, errors.New("contact not found") } var rows []struct{ Name string } err := s.DB().WithContext(ctx).Table("contact_labels"). Select("tags.name"). Joins("JOIN tags ON tags.id = contact_labels.tag_id"). Where("contact_labels.account_id = ? AND contact_labels.contact_id = ? AND tags.deleted_at IS NULL", accountID, contactID). Order("contact_labels.created_at ASC, tags.name ASC"). Scan(&rows).Error if err != nil { return nil, err } labels := make([]string, 0, len(rows)) for _, row := range rows { labels = append(labels, row.Name) } return labels, nil } func (s *ContactService) UpdateLabels(ctx context.Context, accountID, contactID uint, labels []string) ([]string, error) { if _, err := s.repo.FindByAccountAndID(ctx, accountID, contactID); err != nil { return nil, errors.New("contact not found") } normalized := normalizeContactServiceLabels(labels) err := s.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Where("account_id = ? AND contact_id = ?", accountID, contactID).Delete(&model.ContactLabel{}).Error; err != nil { return err } for _, label := range normalized { tag := model.Tag{AccountID: accountID, Name: label} if err := tx.Where("account_id = ? AND name = ?", accountID, label).FirstOrCreate(&tag).Error; err != nil { return err } contactLabel := model.ContactLabel{AccountID: accountID, ContactID: contactID, TagID: tag.ID} if err := tx.Create(&contactLabel).Error; err != nil { return err } } return nil }) if err != nil { return nil, err } return normalized, nil } func normalizeContactServiceLabels(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 mergeContactJSON(current datatypes.JSON, incoming datatypes.JSON) datatypes.JSON { if len(incoming) == 0 { return current } merged := map[string]any{} if len(current) > 0 { _ = json.Unmarshal(current, &merged) } incomingMap := map[string]any{} _ = json.Unmarshal(incoming, &incomingMap) for key, value := range incomingMap { merged[key] = value } bytes, _ := json.Marshal(merged) return datatypes.JSON(bytes) } // ContactableInbox represents an inbox that a contact can be associated with. // Reference: Chatwoot contacts#contactable_inboxes type ContactableInbox struct { Inbox model.Inbox `json:"inbox"` ContactInbox *model.ContactInbox `json:"contact_inbox,omitempty"` SourceID string `json:"source_id,omitempty"` } // GetContactableInboxes returns inboxes that a contact can be added to. // GET /api/v1/accounts/:id/contacts/:contact_id/contactable_inboxes // Reference: Chatwoot contacts#contactable_inboxes — returns inboxes in the account // that the contact is either already in or can be added to. func (s *ContactService) GetContactableInboxes(ctx context.Context, accountID, contactID uint) ([]ContactableInbox, error) { // Verify contact belongs to account _, err := s.repo.FindByAccountAndID(ctx, accountID, contactID) if err != nil { return nil, errors.New("contact not found") } // Get existing contact_inboxes for this contact existingInboxes, err := s.contactInboxSvc.ListByContact(ctx, contactID) if err != nil { return nil, fmt.Errorf("failed to list contact inboxes: %w", err) } result := make([]ContactableInbox, 0, len(existingInboxes)) for _, ci := range existingInboxes { result = append(result, ContactableInbox{ Inbox: ci.Inbox, ContactInbox: &ci, SourceID: ci.SourceID, }) } return result, nil }