1526 lines
49 KiB
Go
1526 lines
49 KiB
Go
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/csv"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"gorm.io/datatypes"
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/gochat/gochat/internal/model"
|
|
"github.com/gochat/gochat/internal/repository"
|
|
"github.com/gochat/gochat/internal/search"
|
|
"github.com/gochat/gochat/internal/worker"
|
|
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
|
|
worker *worker.WorkerPool
|
|
}
|
|
|
|
// 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) SetWorkerPool(wp *worker.WorkerPool) {
|
|
s.worker = wp
|
|
RegisterContactExportJobs(wp, s)
|
|
RegisterContactImportJobs(wp, s)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
type InitiateContactCallRequest struct {
|
|
InboxID uint `json:"inbox_id"`
|
|
ConversationID *uint `json:"conversation_id,omitempty"`
|
|
UserID uint `json:"-"`
|
|
}
|
|
|
|
type InitiateContactCallResponse struct {
|
|
ConversationID uint `json:"conversation_id"`
|
|
InboxID uint `json:"inbox_id"`
|
|
CallSID string `json:"call_sid"`
|
|
ConferenceSID string `json:"conference_sid"`
|
|
}
|
|
|
|
func (s *ContactService) InitiateCall(ctx context.Context, accountID, contactID uint, req InitiateContactCallRequest) (*InitiateContactCallResponse, error) {
|
|
if s == nil || s.repo == nil || s.repo.DB() == nil {
|
|
return nil, errors.New("contact service unavailable")
|
|
}
|
|
if req.UserID == 0 {
|
|
return nil, errors.New("agent required")
|
|
}
|
|
if req.InboxID == 0 {
|
|
return nil, errors.New("inbox_id is required")
|
|
}
|
|
|
|
db := s.repo.DB().WithContext(ctx)
|
|
var out InitiateContactCallResponse
|
|
err := db.Transaction(func(tx *gorm.DB) error {
|
|
var contact model.Contact
|
|
if err := tx.Where("account_id = ? AND id = ?", accountID, contactID).First(&contact).Error; err != nil {
|
|
return err
|
|
}
|
|
if strings.TrimSpace(contact.PhoneNumber) == "" {
|
|
return errors.New("Contact phone number required")
|
|
}
|
|
|
|
var inbox model.Inbox
|
|
if err := tx.Where("account_id = ? AND id = ? AND channel_type = ?", accountID, req.InboxID, string(model.InboxChannelTypeTwilioSMS)).First(&inbox).Error; err != nil {
|
|
return err
|
|
}
|
|
if !contactCallVoiceEnabled(inbox.ChannelConfig) {
|
|
return gorm.ErrRecordNotFound
|
|
}
|
|
|
|
var member model.InboxMember
|
|
if err := tx.Where("inbox_id = ? AND user_id = ?", inbox.ID, req.UserID).First(&member).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
contactInbox, err := ensureVoiceContactInbox(ctx, tx, contact.ID, inbox.ID, contact.PhoneNumber)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
conversation, err := reusableVoiceConversation(ctx, tx, accountID, contact.ID, inbox.ID, req.ConversationID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if conversation == nil {
|
|
conversation = &model.Conversation{
|
|
AccountID: accountID,
|
|
InboxID: inbox.ID,
|
|
ContactID: contact.ID,
|
|
ContactInboxID: &contactInbox.ID,
|
|
Status: string(model.ConversationStatusOpen),
|
|
Priority: string(model.ConversationPriorityLow),
|
|
ChannelType: inbox.ChannelType,
|
|
Channel: inbox.ChannelType,
|
|
}
|
|
if err := repository.NewConversationRepo(tx).Create(ctx, conversation); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
callSID := newVoiceCallSID()
|
|
call := &model.Call{
|
|
AccountID: accountID,
|
|
InboxID: inbox.ID,
|
|
ConversationID: conversation.ID,
|
|
ContactID: contact.ID,
|
|
AcceptedByAgentID: &req.UserID,
|
|
Provider: "twilio",
|
|
Direction: "outgoing",
|
|
Status: "ringing",
|
|
ProviderCallID: callSID,
|
|
CallerType: "User",
|
|
CallerID: req.UserID,
|
|
CallDirection: "outbound",
|
|
AdditionalAttributes: mustContactCallJSON(map[string]any{
|
|
"initiated_at": time.Now().Unix(),
|
|
}),
|
|
}
|
|
if err := tx.Create(call).Error; err != nil {
|
|
return err
|
|
}
|
|
call.ConferenceSID = fmt.Sprintf("conf_account_%d_call_%d", accountID, call.ID)
|
|
if err := tx.Model(call).Update("conference_sid", call.ConferenceSID).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
messageAttrs := mustContactCallJSON(map[string]any{"data": map[string]any{
|
|
"call_id": call.ID,
|
|
"call_sid": call.ProviderCallID,
|
|
"call_source": call.Provider,
|
|
"call_direction": "outbound",
|
|
"status": "ringing",
|
|
}})
|
|
message := &model.Message{
|
|
AccountID: accountID,
|
|
ConversationID: conversation.ID,
|
|
InboxID: inbox.ID,
|
|
SenderID: &req.UserID,
|
|
SenderType: "user",
|
|
Content: "Twilio voice call",
|
|
ContentType: "voice_call",
|
|
MessageType: string(model.MessageTypeOutgoing),
|
|
Status: "sent",
|
|
ContentAttributes: datatypes.JSON(messageAttrs),
|
|
AdditionalAttributes: datatypes.JSON([]byte(`{}`)),
|
|
}
|
|
if err := tx.Create(message).Error; err != nil {
|
|
return err
|
|
}
|
|
call.MessageID = &message.ID
|
|
if err := tx.Model(call).Update("message_id", message.ID).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
now := time.Now().Unix()
|
|
updates := map[string]any{"last_activity_at": now, "last_message_at": now}
|
|
if conversation.ContactInboxID == nil {
|
|
updates["contact_inbox_id"] = contactInbox.ID
|
|
}
|
|
if err := tx.Model(conversation).Updates(updates).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
conversationID := conversation.ID
|
|
if conversation.DisplayID != nil && *conversation.DisplayID != 0 {
|
|
conversationID = *conversation.DisplayID
|
|
}
|
|
out = InitiateContactCallResponse{ConversationID: conversationID, InboxID: inbox.ID, CallSID: call.ProviderCallID, ConferenceSID: call.ConferenceSID}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
func contactCallVoiceEnabled(rawConfig string) bool {
|
|
config := map[string]any{}
|
|
if rawConfig != "" {
|
|
_ = json.Unmarshal([]byte(rawConfig), &config)
|
|
}
|
|
if value, ok := config["voice_enabled"]; ok {
|
|
switch v := value.(type) {
|
|
case bool:
|
|
return v
|
|
case string:
|
|
return strings.EqualFold(v, "true")
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func ensureVoiceContactInbox(ctx context.Context, tx *gorm.DB, contactID, inboxID uint, sourceID string) (*model.ContactInbox, error) {
|
|
var contactInbox model.ContactInbox
|
|
if err := tx.WithContext(ctx).Where("contact_id = ? AND inbox_id = ?", contactID, inboxID).First(&contactInbox).Error; err == nil {
|
|
return &contactInbox, nil
|
|
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, err
|
|
}
|
|
|
|
hmacToken, err := generateToken(24)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
pubsubToken, err := generateToken(24)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
contactInbox = model.ContactInbox{ContactID: contactID, InboxID: inboxID, SourceID: sourceID, HMACToken: hmacToken, PubsubToken: pubsubToken}
|
|
if err := tx.WithContext(ctx).Create(&contactInbox).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &contactInbox, nil
|
|
}
|
|
|
|
func reusableVoiceConversation(ctx context.Context, tx *gorm.DB, accountID, contactID, inboxID uint, displayID *uint) (*model.Conversation, error) {
|
|
if displayID == nil || *displayID == 0 {
|
|
return nil, nil
|
|
}
|
|
var conversation model.Conversation
|
|
err := tx.WithContext(ctx).Where("account_id = ? AND display_id = ?", accountID, *displayID).First(&conversation).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if conversation.InboxID != inboxID || conversation.ContactID != contactID || conversation.Status != string(model.ConversationStatusOpen) {
|
|
return nil, nil
|
|
}
|
|
return &conversation, nil
|
|
}
|
|
|
|
func newVoiceCallSID() string {
|
|
return "CA" + strings.ReplaceAll(uuid.NewString(), "-", "")
|
|
}
|
|
|
|
func mustContactCallJSON(value any) []byte {
|
|
data, err := json.Marshal(value)
|
|
if err != nil {
|
|
return []byte(`{}`)
|
|
}
|
|
return data
|
|
}
|
|
|
|
// 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 s.worker != nil {
|
|
_, err := s.worker.Enqueue(ctx, TaskTypeContactExport, contactExportJob{ExportID: export.ID}, worker.WithQueue("low"), worker.WithMaxAttempts(3), worker.WithIdempotencyKey(fmt.Sprintf("contact-export:%d", export.ID)))
|
|
if err != nil {
|
|
s.repo.DB().WithContext(ctx).Model(export).Updates(map[string]any{
|
|
"status": string(model.DataImportStatusFailed),
|
|
"error": err.Error(),
|
|
})
|
|
return export, err
|
|
}
|
|
return export, nil
|
|
}
|
|
return s.performContactExport(ctx, export.ID)
|
|
}
|
|
|
|
func (s *ContactService) performContactExport(ctx context.Context, exportID uint) (*model.ContactExport, error) {
|
|
if !s.Ready() {
|
|
return nil, errors.New("contact service not ready")
|
|
}
|
|
|
|
var export model.ContactExport
|
|
if err := s.repo.DB().WithContext(ctx).First(&export, exportID).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if export.Status == string(model.DataImportStatusCompleted) {
|
|
return &export, nil
|
|
}
|
|
|
|
var account model.Account
|
|
if err := s.repo.DB().WithContext(ctx).First(&account, export.AccountID).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
|
|
}
|
|
|
|
req := contactExportRequestFromRecord(export)
|
|
csvData, rowCount, err := s.GenerateContactExportCSV(ctx, export.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", export.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 contactExportRequestFromRecord(export model.ContactExport) ContactExportRequest {
|
|
var columnNames []string
|
|
if len(export.ColumnNames) > 0 {
|
|
_ = json.Unmarshal(export.ColumnNames, &columnNames)
|
|
}
|
|
var filters struct {
|
|
Payload []ContactExportFilterCondition `json:"payload"`
|
|
Label string `json:"label"`
|
|
}
|
|
if len(export.FilterParams) > 0 {
|
|
_ = json.Unmarshal(export.FilterParams, &filters)
|
|
}
|
|
return ContactExportRequest{ColumnNames: columnNames, Payload: filters.Payload, Label: filters.Label}
|
|
}
|
|
|
|
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")
|
|
}
|
|
csvData, err := io.ReadAll(r)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read import file: %w", err)
|
|
}
|
|
var userIDPtr *uint
|
|
if userID != 0 {
|
|
userIDPtr = &userID
|
|
}
|
|
config, _ := json.Marshal(contactImportConfig{CSVBase64: base64.StdEncoding.EncodeToString(csvData)})
|
|
dataImport := &model.DataImport{AccountID: accountID, UserID: userIDPtr, DataType: "contacts", Status: string(model.DataImportStatusPending), ImportConfig: config}
|
|
if err := s.repo.DB().WithContext(ctx).Create(dataImport).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if s.worker != nil {
|
|
_, err := s.worker.Enqueue(ctx, TaskTypeContactImport, contactImportJob{ImportID: dataImport.ID}, worker.WithQueue("low"), worker.WithMaxAttempts(3), worker.WithIdempotencyKey(fmt.Sprintf("contact-import:%d", dataImport.ID)))
|
|
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
|
|
}
|
|
return dataImport, nil
|
|
}
|
|
return s.performContactImport(ctx, dataImport.ID)
|
|
}
|
|
|
|
func (s *ContactService) performContactImport(ctx context.Context, importID uint) (*model.DataImport, error) {
|
|
if !s.Ready() {
|
|
return nil, errors.New("contact service not ready")
|
|
}
|
|
var dataImport model.DataImport
|
|
if err := s.repo.DB().WithContext(ctx).First(&dataImport, importID).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if dataImport.Status == string(model.DataImportStatusCompleted) {
|
|
return &dataImport, nil
|
|
}
|
|
var config contactImportConfig
|
|
if err := json.Unmarshal(dataImport.ImportConfig, &config); err != nil {
|
|
return nil, fmt.Errorf("unmarshal import config: %w", err)
|
|
}
|
|
csvData, err := base64.StdEncoding.DecodeString(config.CSVBase64)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode import csv: %w", 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, dataImport.AccountID, bytes.NewReader(csvData))
|
|
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
|
|
}
|
|
s.indexContact(ctx, existing)
|
|
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
|
|
}
|
|
s.indexContact(ctx, contact)
|
|
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
|
|
}
|
|
|
|
// ListAttachments returns a contact's message attachments across account-scoped conversations.
|
|
// Reference: Chatwoot Api::V1::Accounts::Contacts::AttachmentsController#index.
|
|
func (s *ContactService) ListAttachments(ctx context.Context, accountID, contactID uint, offset, limit int) ([]model.Attachment, int64, error) {
|
|
if _, err := s.repo.FindByAccountAndID(ctx, accountID, contactID); err != nil {
|
|
return nil, 0, errors.New("contact not found")
|
|
}
|
|
|
|
if limit <= 0 {
|
|
limit = 100
|
|
}
|
|
db := s.repo.DB()
|
|
scope := db.WithContext(ctx).
|
|
Where("account_id = ? AND message_id IN (SELECT messages.id FROM messages JOIN conversations ON conversations.id = messages.conversation_id WHERE conversations.account_id = ? AND conversations.contact_id = ?)", accountID, accountID, contactID)
|
|
|
|
var total int64
|
|
if err := scope.Model(&model.Attachment{}).Count(&total).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
var attachments []model.Attachment
|
|
if err := scope.
|
|
Preload("Message").
|
|
Order("created_at DESC, id DESC").
|
|
Offset(offset).
|
|
Limit(limit).
|
|
Find(&attachments).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
return attachments, total, nil
|
|
}
|