2002 lines
75 KiB
Go
2002 lines
75 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gochat/gochat/internal/automation"
|
|
"github.com/gochat/gochat/internal/channel"
|
|
"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"
|
|
|
|
"gorm.io/datatypes"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// ConversationService implements business logic for Conversation operations.
|
|
// Reference: Chatwoot app/controllers/api/v1/conversations_controller.rb
|
|
type ConversationService struct {
|
|
repo *repository.ConversationRepo
|
|
msgRepo *repository.MessageRepo
|
|
dispatcher *channel.Dispatcher
|
|
inboxMemberSvc *InboxMemberService
|
|
accountUserRepo *repository.AccountUserRepo
|
|
teamRepo *repository.TeamRepo
|
|
teamMemberRepo *repository.TeamMemberRepo
|
|
searchIndexer SearchIndexer
|
|
appliedSlaSvc *AppliedSlaService
|
|
transcriptMailer automation.AutomationTranscriptDeliverer
|
|
worker *worker.WorkerPool
|
|
}
|
|
|
|
// NewConversationService creates a new Conversation service.
|
|
func NewConversationService(repo *repository.ConversationRepo, msgRepo *repository.MessageRepo, dispatcher *channel.Dispatcher, inboxMemberSvc *InboxMemberService, accountUserRepo *repository.AccountUserRepo, teamRepo *repository.TeamRepo, teamMemberRepo *repository.TeamMemberRepo) *ConversationService {
|
|
return &ConversationService{repo: repo, msgRepo: msgRepo, dispatcher: dispatcher, inboxMemberSvc: inboxMemberSvc, accountUserRepo: accountUserRepo, teamRepo: teamRepo, teamMemberRepo: teamMemberRepo, transcriptMailer: automation.NewEnvAutomationTranscriptDeliverer()}
|
|
}
|
|
|
|
func (s *ConversationService) SetSearchIndexer(indexer SearchIndexer) {
|
|
s.searchIndexer = indexer
|
|
}
|
|
|
|
func (s *ConversationService) SetAppliedSlaService(appliedSlaSvc *AppliedSlaService) {
|
|
s.appliedSlaSvc = appliedSlaSvc
|
|
}
|
|
|
|
func (s *ConversationService) SetTranscriptDeliverer(deliverer automation.AutomationTranscriptDeliverer) {
|
|
s.transcriptMailer = deliverer
|
|
}
|
|
|
|
func (s *ConversationService) SetWorkerPool(wp *worker.WorkerPool) {
|
|
s.worker = wp
|
|
RegisterConversationDeleteJobs(wp, s)
|
|
}
|
|
|
|
func (s *ConversationService) DB() *gorm.DB {
|
|
if s == nil || s.repo == nil {
|
|
return nil
|
|
}
|
|
return s.repo.DB()
|
|
}
|
|
|
|
func (s *ConversationService) indexConversation(ctx context.Context, conversation *model.Conversation) {
|
|
if s.searchIndexer != nil {
|
|
logSearchIndexError("conversation", conversation.ID, s.searchIndexer.IndexConversation(ctx, conversation))
|
|
}
|
|
}
|
|
|
|
func (s *ConversationService) deleteConversationIndex(ctx context.Context, accountID uint, id uint) {
|
|
if s.searchIndexer != nil {
|
|
logSearchIndexError("conversation", id, s.searchIndexer.DeleteConversation(ctx, accountID, id))
|
|
}
|
|
}
|
|
|
|
func (s *ConversationService) indexMessage(ctx context.Context, message *model.Message) {
|
|
if s.searchIndexer != nil {
|
|
logSearchIndexError("message", message.ID, s.searchIndexer.IndexMessage(ctx, message))
|
|
}
|
|
}
|
|
|
|
// dispatchConversationEvent is a helper to build and dispatch a conversation event.
|
|
func (s *ConversationService) dispatchConversationEvent(ctx context.Context, eventType channel.EventType, conversation *model.Conversation) {
|
|
s.dispatchConversationEventWithData(ctx, eventType, conversation, nil)
|
|
}
|
|
|
|
func (s *ConversationService) dispatchConversationEventWithData(ctx context.Context, eventType channel.EventType, conversation *model.Conversation, data map[string]interface{}) {
|
|
event := channel.NewChannelEvent(eventType, channel.ChannelType(conversation.ChannelType), conversation.AccountID, conversation.InboxID)
|
|
event.ConversationID = conversation.ID
|
|
event.ContactID = conversation.ContactID
|
|
if conversation.AssigneeID != nil {
|
|
event.UserID = *conversation.AssigneeID
|
|
}
|
|
for key, value := range data {
|
|
event.Data[key] = value
|
|
}
|
|
event.Data["conversation"] = conversation
|
|
applogger.L().Infof("dispatching event %s for conversation %d", eventType, conversation.ID)
|
|
if err := s.dispatcher.Dispatch(ctx, event); err != nil {
|
|
applogger.L().Errorf("failed to dispatch event %s for conversation %d: %v", eventType, conversation.ID, err)
|
|
}
|
|
}
|
|
|
|
func changedAttributes(changes map[string][2]interface{}) map[string]interface{} {
|
|
out := make(map[string]interface{}, len(changes))
|
|
for attr, values := range changes {
|
|
if fmt.Sprintf("%v", values[0]) == fmt.Sprintf("%v", values[1]) {
|
|
continue
|
|
}
|
|
out[attr] = map[string]interface{}{
|
|
"from": values[0],
|
|
"to": values[1],
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func eventDataWithChanges(changes map[string]interface{}) map[string]interface{} {
|
|
if len(changes) == 0 {
|
|
return nil
|
|
}
|
|
return map[string]interface{}{"changed_attributes": changes}
|
|
}
|
|
|
|
// ListByAccount retrieves all conversations for an account.
|
|
func (s *ConversationService) ListByAccount(ctx context.Context, accountID uint, offset, limit int) ([]model.Conversation, int64, error) {
|
|
return s.repo.FindByAccount(ctx, accountID, offset, limit)
|
|
}
|
|
|
|
// ListByInbox retrieves conversations for an inbox within an account.
|
|
func (s *ConversationService) ListByInbox(ctx context.Context, accountID, inboxID uint, offset, limit int) ([]model.Conversation, int64, error) {
|
|
return s.repo.FindByInbox(ctx, accountID, inboxID, offset, limit)
|
|
}
|
|
|
|
// ListByStatus retrieves conversations filtered by status.
|
|
func (s *ConversationService) ListByStatus(ctx context.Context, accountID uint, status string, offset, limit int) ([]model.Conversation, int64, error) {
|
|
return s.repo.FindByStatus(ctx, accountID, model.ConversationStatus(status), offset, limit)
|
|
}
|
|
|
|
// ListByAssignee retrieves conversations assigned to a specific agent.
|
|
func (s *ConversationService) ListByAssignee(ctx context.Context, accountID, assigneeID uint, offset, limit int) ([]model.Conversation, int64, error) {
|
|
return s.repo.FindByAssignee(ctx, accountID, assigneeID, offset, limit)
|
|
}
|
|
|
|
// ListRecentByContact retrieves recent conversations for a contact.
|
|
func (s *ConversationService) ListRecentByContact(ctx context.Context, accountID, contactID uint, inboxID *uint, limit int) ([]model.Conversation, error) {
|
|
if limit <= 0 {
|
|
limit = 20
|
|
}
|
|
return s.repo.FindRecentByContact(ctx, accountID, contactID, inboxID, limit)
|
|
}
|
|
|
|
// ListUnassigned retrieves unassigned open conversations.
|
|
func (s *ConversationService) ListUnassigned(ctx context.Context, accountID uint, offset, limit int) ([]model.Conversation, int64, error) {
|
|
return s.repo.FindUnassigned(ctx, accountID, offset, limit)
|
|
}
|
|
|
|
// GetByID retrieves a single conversation.
|
|
func (s *ConversationService) GetByID(ctx context.Context, id uint) (*model.Conversation, error) {
|
|
return s.repo.FindByID(ctx, id)
|
|
}
|
|
|
|
// GetByAccountAndID retrieves a conversation scoped to an account.
|
|
func (s *ConversationService) GetByAccountAndID(ctx context.Context, accountID, id uint) (*model.Conversation, error) {
|
|
return s.repo.FindByAccountAndID(ctx, accountID, id)
|
|
}
|
|
|
|
// GetByAccountAndDisplayIDOrID resolves Chatwoot conversation route IDs.
|
|
func (s *ConversationService) GetByAccountAndDisplayIDOrID(ctx context.Context, accountID, routeID uint) (*model.Conversation, error) {
|
|
return s.repo.FindByAccountAndDisplayIDOrID(ctx, accountID, routeID)
|
|
}
|
|
|
|
// GetInboxAssistant returns the Captain assistant connected to the conversation's inbox.
|
|
// Reference: Chatwoot enterprise ConversationsController#inbox_assistant.
|
|
func (s *ConversationService) GetInboxAssistant(ctx context.Context, accountID, routeID uint) (*model.CaptainAssistant, error) {
|
|
conversation, err := s.repo.FindByAccountAndDisplayIDOrID(ctx, accountID, routeID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var assistant model.CaptainAssistant
|
|
err = s.repo.DB().WithContext(ctx).
|
|
Joins("JOIN captain_inboxes ON captain_inboxes.captain_assistant_id = captain_assistants.id").
|
|
Where("captain_inboxes.account_id = ? AND captain_inboxes.inbox_id = ? AND captain_assistants.account_id = ?", accountID, conversation.InboxID, accountID).
|
|
Order("captain_inboxes.id DESC").
|
|
First(&assistant).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &assistant, nil
|
|
}
|
|
|
|
// ListReportingEvents returns conversation-scoped reporting events in Chatwoot order.
|
|
// Reference: Chatwoot enterprise ConversationsController#reporting_events.
|
|
func (s *ConversationService) ListReportingEvents(ctx context.Context, accountID, routeID uint) ([]model.ReportingEvent, error) {
|
|
conversation, err := s.repo.FindByAccountAndDisplayIDOrID(ctx, accountID, routeID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var events []model.ReportingEvent
|
|
err = s.repo.DB().WithContext(ctx).
|
|
Where("account_id = ? AND conversation_id = ?", accountID, conversation.ID).
|
|
Order("created_at ASC").
|
|
Find(&events).Error
|
|
return events, err
|
|
}
|
|
|
|
// CreateConversationRequest is the DTO for creating a conversation.
|
|
// Reference: Chatwoot app/controllers/api/v1/accounts/conversations_controller.rb #create
|
|
// Supports creating conversation with an initial message (like Chatwoot's ConversationBuilder).
|
|
type CreateConversationRequest struct {
|
|
InboxID uint `json:"inbox_id" validate:"required"`
|
|
ContactID uint `json:"contact_id" validate:"required"`
|
|
Status string `json:"status,omitempty" validate:"omitempty,oneof=open resolved pending snoozed"`
|
|
Priority string `json:"priority,omitempty" validate:"omitempty,oneof=urgent high medium low"`
|
|
SlaPolicyID *uint `json:"sla_policy_id,omitempty"`
|
|
// Initial message fields (Chatwoot: conversation + message created together)
|
|
MessageContent string `json:"message_content,omitempty"`
|
|
MessageType string `json:"message_type,omitempty" validate:"omitempty,oneof=outgoing incoming"`
|
|
}
|
|
|
|
// Create creates a new conversation.
|
|
func (s *ConversationService) Create(ctx context.Context, accountID uint, req CreateConversationRequest) (*model.Conversation, error) {
|
|
if err := pkgvalidator.ValidateStruct(req); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
status := model.ConversationStatusOpen
|
|
if req.Status != "" {
|
|
status = model.ConversationStatus(req.Status)
|
|
}
|
|
priority := model.ConversationPriorityLow
|
|
if req.Priority != "" {
|
|
priority = model.ConversationPriority(req.Priority)
|
|
}
|
|
if err := s.validateSlaPolicy(ctx, accountID, req.SlaPolicyID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
conversation := &model.Conversation{
|
|
AccountID: accountID,
|
|
InboxID: req.InboxID,
|
|
ContactID: req.ContactID,
|
|
Status: string(status),
|
|
Priority: string(priority),
|
|
SlaPolicyID: req.SlaPolicyID,
|
|
}
|
|
|
|
if err := s.repo.Create(ctx, conversation); err != nil {
|
|
applogger.L().Errorf("Failed to create conversation: %v", err)
|
|
return nil, err
|
|
}
|
|
|
|
if err := s.ensureAppliedSla(ctx, accountID, conversation); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Dispatch EventConversationCreated
|
|
s.dispatchConversationEvent(ctx, channel.EventConversationCreated, conversation)
|
|
s.indexConversation(ctx, conversation)
|
|
|
|
// If status is open, also dispatch EventConversationOpened
|
|
if status == model.ConversationStatusOpen {
|
|
s.dispatchConversationEvent(ctx, channel.EventConversationOpened, conversation)
|
|
}
|
|
|
|
// Chatwoot: Create initial message if message_content is provided
|
|
// Reference: Chatwoot ConversationBuilder builds conversation + first message together
|
|
if req.MessageContent != "" {
|
|
msgType := req.MessageType
|
|
if msgType == "" {
|
|
msgType = "outgoing"
|
|
}
|
|
initialMsg := &model.Message{
|
|
AccountID: accountID,
|
|
ConversationID: conversation.ID,
|
|
InboxID: req.InboxID,
|
|
Content: req.MessageContent,
|
|
MessageType: msgType,
|
|
ContentType: "text",
|
|
SenderType: "user",
|
|
}
|
|
if err := s.msgRepo.Create(ctx, initialMsg); err != nil {
|
|
applogger.L().Errorf("Failed to create initial message for conversation %d: %v", conversation.ID, err)
|
|
// Non-critical: conversation was created, message creation failure is logged but not fatal
|
|
} else {
|
|
s.indexMessage(ctx, initialMsg)
|
|
event := channel.NewChannelEvent(channel.EventMessageCreated, channel.ChannelAPI, accountID, req.InboxID)
|
|
event.ConversationID = conversation.ID
|
|
event.ContactID = conversation.ContactID
|
|
event.Data["message"] = initialMsg
|
|
event.Data["conversation"] = conversation
|
|
if err := s.dispatcher.Dispatch(ctx, event); err != nil {
|
|
applogger.L().Errorf("failed to dispatch event %s for message %d: %v", channel.EventMessageCreated, initialMsg.ID, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
return conversation, nil
|
|
}
|
|
|
|
// UpdateConversationRequest is the DTO for updating a conversation.
|
|
type UpdateConversationRequest struct {
|
|
Status string `json:"status,omitempty" validate:"omitempty,oneof=open resolved pending snoozed"`
|
|
Priority string `json:"priority,omitempty" validate:"omitempty,oneof=urgent high medium low"`
|
|
SlaPolicyID *uint `json:"sla_policy_id,omitempty"`
|
|
}
|
|
|
|
// Update modifies an existing conversation.
|
|
func (s *ConversationService) Update(ctx context.Context, accountID, id uint, req UpdateConversationRequest) (*model.Conversation, error) {
|
|
if err := pkgvalidator.ValidateStruct(req); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
conversation, err := s.repo.FindByAccountAndID(ctx, accountID, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
oldStatus := conversation.Status
|
|
oldPriority := conversation.Priority
|
|
var oldSlaPolicyID interface{}
|
|
if conversation.SlaPolicyID != nil {
|
|
oldSlaPolicyID = *conversation.SlaPolicyID
|
|
}
|
|
|
|
if req.Status != "" {
|
|
conversation.Status = req.Status
|
|
}
|
|
if req.Priority != "" {
|
|
conversation.Priority = req.Priority
|
|
}
|
|
if req.SlaPolicyID != nil {
|
|
if *req.SlaPolicyID == 0 {
|
|
return nil, errors.New("sla policy cannot be removed from conversation")
|
|
}
|
|
if conversation.SlaPolicyID != nil && *conversation.SlaPolicyID != *req.SlaPolicyID {
|
|
return nil, errors.New("conversation already has a different sla")
|
|
}
|
|
if err := s.validateSlaPolicy(ctx, accountID, req.SlaPolicyID); err != nil {
|
|
return nil, err
|
|
}
|
|
conversation.SlaPolicyID = req.SlaPolicyID
|
|
}
|
|
|
|
if err := s.repo.Update(ctx, conversation); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := s.ensureAppliedSla(ctx, accountID, conversation); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var newSlaPolicyID interface{}
|
|
if conversation.SlaPolicyID != nil {
|
|
newSlaPolicyID = *conversation.SlaPolicyID
|
|
}
|
|
changes := changedAttributes(map[string][2]interface{}{
|
|
"status": {oldStatus, conversation.Status},
|
|
"priority": {oldPriority, conversation.Priority},
|
|
"sla_policy_id": {oldSlaPolicyID, newSlaPolicyID},
|
|
})
|
|
changeData := eventDataWithChanges(changes)
|
|
|
|
// Dispatch EventConversationUpdated
|
|
s.dispatchConversationEventWithData(ctx, channel.EventConversationUpdated, conversation, changeData)
|
|
s.indexConversation(ctx, conversation)
|
|
|
|
// If status changed, dispatch appropriate status event
|
|
if req.Status != "" && req.Status != oldStatus {
|
|
newStatus := model.ConversationStatus(req.Status)
|
|
switch newStatus {
|
|
case model.ConversationStatusResolved:
|
|
s.dispatchConversationEventWithData(ctx, channel.EventConversationResolved, conversation, changeData)
|
|
case model.ConversationStatusOpen:
|
|
s.dispatchConversationEventWithData(ctx, channel.EventConversationOpened, conversation, changeData)
|
|
}
|
|
}
|
|
|
|
return conversation, nil
|
|
}
|
|
|
|
func (s *ConversationService) ensureAppliedSla(ctx context.Context, accountID uint, conversation *model.Conversation) error {
|
|
if s.appliedSlaSvc == nil || conversation == nil || conversation.SlaPolicyID == nil || *conversation.SlaPolicyID == 0 {
|
|
return nil
|
|
}
|
|
_, err := s.appliedSlaSvc.CreateFromConversation(ctx, accountID, conversation.ID, *conversation.SlaPolicyID)
|
|
return err
|
|
}
|
|
|
|
func (s *ConversationService) validateSlaPolicy(ctx context.Context, accountID uint, slaPolicyID *uint) error {
|
|
if s.appliedSlaSvc == nil || slaPolicyID == nil || *slaPolicyID == 0 {
|
|
return nil
|
|
}
|
|
return s.appliedSlaSvc.ValidateSlaPolicy(ctx, accountID, *slaPolicyID)
|
|
}
|
|
|
|
// AssignAgentRequest is the DTO for assigning an agent to a conversation.
|
|
type AssignAgentRequest struct {
|
|
AssigneeID uint `json:"assignee_id" validate:"required"`
|
|
}
|
|
|
|
// AssignAgent assigns a conversation to an agent.
|
|
func (s *ConversationService) AssignAgent(ctx context.Context, accountID, id uint, assigneeID uint) (*model.Conversation, error) {
|
|
conversation, err := s.repo.FindByAccountAndID(ctx, accountID, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if assigneeID == 0 {
|
|
// Unassign: dispatch EventConversationUnassigned
|
|
if err := s.repo.AssignAgent(ctx, conversation.ID, 0); err != nil {
|
|
return nil, err
|
|
}
|
|
conversation.AssigneeID = nil
|
|
event := channel.NewChannelEvent(channel.EventConversationUnassigned, channel.ChannelType(conversation.ChannelType), conversation.AccountID, conversation.InboxID)
|
|
event.ConversationID = conversation.ID
|
|
event.ContactID = conversation.ContactID
|
|
event.Data["conversation"] = conversation
|
|
applogger.L().Infof("dispatching event %s for conversation %d", channel.EventConversationUnassigned, conversation.ID)
|
|
if err := s.dispatcher.Dispatch(ctx, event); err != nil {
|
|
applogger.L().Errorf("failed to dispatch event %s: %v", channel.EventConversationUnassigned, err)
|
|
}
|
|
s.indexConversation(ctx, conversation)
|
|
return conversation, nil
|
|
}
|
|
|
|
// Authorization: assignee must be a valid agent/admin in this account.
|
|
// Reference: Chatwoot Conversations::AssignmentService — validates assignee is account member
|
|
if s.accountUserRepo != nil {
|
|
isMember, err := s.accountUserRepo.IsAgentOrAdmin(ctx, accountID, assigneeID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to check assignee role: %w", err)
|
|
}
|
|
if !isMember {
|
|
return nil, errors.New("assignee is not an agent or administrator in this account")
|
|
}
|
|
}
|
|
|
|
// Validate: the assignee must be a member (agent) of the conversation's inbox.
|
|
// This enforces seat assignment routing — agents can only be assigned to conversations
|
|
// in inboxes they are members of.
|
|
if s.inboxMemberSvc != nil && !s.inboxMemberSvc.IsMemberOfInbox(ctx, conversation.InboxID, assigneeID) {
|
|
return nil, errors.New("assignee is not a member of the conversation's inbox")
|
|
}
|
|
|
|
if err := s.ensureAssigneeHasInboxCapacity(ctx, accountID, conversation, assigneeID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := s.repo.AssignAgent(ctx, conversation.ID, assigneeID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
conversation.AssigneeID = &assigneeID
|
|
|
|
// Dispatch EventConversationAssigned
|
|
event := channel.NewChannelEvent(channel.EventConversationAssigned, channel.ChannelType(conversation.ChannelType), conversation.AccountID, conversation.InboxID)
|
|
event.ConversationID = conversation.ID
|
|
event.ContactID = conversation.ContactID
|
|
event.UserID = assigneeID
|
|
event.Data["conversation"] = conversation
|
|
applogger.L().Infof("dispatching event %s for conversation %d", channel.EventConversationAssigned, conversation.ID)
|
|
if err := s.dispatcher.Dispatch(ctx, event); err != nil {
|
|
applogger.L().Errorf("failed to dispatch event %s: %v", channel.EventConversationAssigned, err)
|
|
}
|
|
s.indexConversation(ctx, conversation)
|
|
|
|
return conversation, nil
|
|
}
|
|
|
|
// UnassignAgent removes the agent assignment from a conversation.
|
|
// Convenience wrapper around AssignAgent(id, 0) — sends EventConversationUnassigned.
|
|
// Reference: Chatwoot conversations_controller.rb #unassign
|
|
func (s *ConversationService) UnassignAgent(ctx context.Context, accountID, id uint) (*model.Conversation, error) {
|
|
return s.AssignAgent(ctx, accountID, id, 0)
|
|
}
|
|
|
|
// ToggleStatusRequest is the DTO for toggling conversation status.
|
|
// Reference: Chatwoot app/controllers/api/v1/accounts/conversations_controller.rb #toggle_status
|
|
type ToggleStatusRequest struct {
|
|
Status string `json:"status" validate:"omitempty,oneof=open resolved pending snoozed"`
|
|
AssigneeID *uint `json:"assignee_id,omitempty"` // Chatwoot: auto-assign on reopen
|
|
SnoozedUntil *int64 `json:"snoozed_until,omitempty"` // Chatwoot: snooze with wake-up time (unix timestamp)
|
|
UserID *uint `json:"user_id,omitempty"` // Chatwoot: should_assign_conversation — auto-assign to agent who opens
|
|
IsBot bool `json:"is_bot,omitempty"` // Chatwoot: pending_to_open_by_bot — agent bot triggers handoff
|
|
}
|
|
|
|
// ToggleStatus toggles the conversation status with Chatwoot-compatible logic:
|
|
// - Reopening a resolved conversation auto-assigns to previous agent or specified assignee
|
|
// - Snoozed conversations require snoozed_until timestamp
|
|
func (s *ConversationService) ToggleStatus(ctx context.Context, accountID, id uint, req ToggleStatusRequest) (*model.Conversation, error) {
|
|
if err := pkgvalidator.ValidateStruct(req); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
conversation, err := s.repo.FindByAccountAndID(ctx, accountID, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
oldStatus := model.ConversationStatus(conversation.Status)
|
|
newStatus := model.ConversationStatus(req.Status)
|
|
if strings.TrimSpace(req.Status) == "" {
|
|
if oldStatus == model.ConversationStatusOpen {
|
|
newStatus = model.ConversationStatusResolved
|
|
} else {
|
|
newStatus = model.ConversationStatusOpen
|
|
}
|
|
}
|
|
|
|
// Reference: Chatwoot conversations_controller#toggle_status
|
|
// 1. pending_to_open_by_bot: AgentBot moves pending→open triggers bot_handoff!
|
|
if oldStatus == model.ConversationStatusPending && newStatus == model.ConversationStatusOpen && req.IsBot {
|
|
// Bot handoff: transition from pending to open via agent bot
|
|
// Chatwoot: @conversation.bot_handoff! sets status to open and fires handoff event
|
|
if err := s.repo.ToggleStatus(ctx, conversation.ID, model.ConversationStatusOpen); err != nil {
|
|
return nil, err
|
|
}
|
|
conversation.Status = string(model.ConversationStatusOpen)
|
|
// Fire bot handoff event (Chatwoot dispatches conversation.bot_handoff!)
|
|
changes := changedAttributes(map[string][2]interface{}{"status": {string(oldStatus), conversation.Status}})
|
|
s.dispatchConversationEventWithData(ctx, channel.EventConversationOpened, conversation, eventDataWithChanges(changes))
|
|
s.indexConversation(ctx, conversation)
|
|
return conversation, nil
|
|
}
|
|
|
|
// 2. should_assign_conversation: Agent user opens conversation → auto-assign to themselves
|
|
// Chatwoot: assign_conversation if should_assign_conversation? (status=open, user is agent)
|
|
if newStatus == model.ConversationStatusOpen && req.UserID != nil && !req.IsBot {
|
|
// Auto-assign to the agent who opened it
|
|
conversation.AssigneeID = req.UserID
|
|
}
|
|
|
|
if err := s.repo.ToggleStatus(ctx, conversation.ID, newStatus); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Chatwoot: on reopen, auto-assign to previous agent if no assignee specified
|
|
// Reference: Chatwoot Conversations::StatusChangeService auto-assigns on reopen
|
|
if newStatus == model.ConversationStatusOpen && conversation.Status == string(model.ConversationStatusResolved) {
|
|
if req.AssigneeID != nil {
|
|
conversation.AssigneeID = req.AssigneeID
|
|
} else if conversation.AssigneeID != nil {
|
|
// Keep previous assignee on reopen (Chatwoot behavior)
|
|
} else {
|
|
// No previous assignee - will need auto-assignment logic
|
|
// Production note: Auto-assignment based on inbox round-robin needs InboxMemberSvc
|
|
}
|
|
}
|
|
|
|
// Chatwoot: snoozed_until for snoozed conversations
|
|
if newStatus == model.ConversationStatusSnoozed && req.SnoozedUntil != nil {
|
|
conversation.SnoozedUntil = req.SnoozedUntil
|
|
}
|
|
|
|
conversation.Status = string(newStatus)
|
|
|
|
// Chatwoot: set resolved_at/resumed_at timestamps on status transitions
|
|
// Reference: Chatwoot Conversation model has resolved_at and reopened_at (resumed_at)
|
|
now := time.Now()
|
|
if newStatus == model.ConversationStatusResolved {
|
|
conversation.ResolvedAt = &now
|
|
} else if newStatus == model.ConversationStatusOpen && oldStatus == model.ConversationStatusResolved {
|
|
conversation.ResumedAt = &now
|
|
}
|
|
|
|
// Persist timestamp changes
|
|
if err := s.repo.Update(ctx, conversation); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Dispatch appropriate status event based on new status
|
|
changes := changedAttributes(map[string][2]interface{}{"status": {string(oldStatus), conversation.Status}})
|
|
changeData := eventDataWithChanges(changes)
|
|
switch newStatus {
|
|
case model.ConversationStatusResolved:
|
|
s.dispatchConversationEventWithData(ctx, channel.EventConversationResolved, conversation, changeData)
|
|
case model.ConversationStatusOpen:
|
|
s.dispatchConversationEventWithData(ctx, channel.EventConversationOpened, conversation, changeData)
|
|
default:
|
|
// For pending/snoozed, dispatch generic updated event
|
|
s.dispatchConversationEventWithData(ctx, channel.EventConversationUpdated, conversation, changeData)
|
|
}
|
|
s.indexConversation(ctx, conversation)
|
|
|
|
return conversation, nil
|
|
}
|
|
|
|
// UpdateLabelsRequest is the DTO for updating conversation labels.
|
|
type UpdateLabelsRequest struct {
|
|
Labels []string `json:"labels" validate:"required"`
|
|
}
|
|
|
|
// UpdateLabels updates the labels on a conversation.
|
|
func (s *ConversationService) UpdateLabels(ctx context.Context, accountID, id uint, labels []string) (*model.Conversation, error) {
|
|
conversation, err := s.repo.FindByAccountAndID(ctx, accountID, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Convert labels array to JSON string for storage
|
|
labelsJSON := ""
|
|
for i, label := range labels {
|
|
if i > 0 {
|
|
labelsJSON += ","
|
|
}
|
|
labelsJSON += label
|
|
}
|
|
|
|
if err := s.repo.UpdateLabels(ctx, conversation.ID, labelsJSON); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
conversation.Labels = labelsJSON
|
|
|
|
// Dispatch EventConversationLabelsUpdated
|
|
s.dispatchConversationEvent(ctx, channel.EventConversationLabelsUpdated, conversation)
|
|
s.indexConversation(ctx, conversation)
|
|
|
|
return conversation, nil
|
|
}
|
|
|
|
// Delete soft-deletes a conversation.
|
|
func (s *ConversationService) Delete(ctx context.Context, accountID, id uint) error {
|
|
conversation, err := s.repo.FindByAccountAndID(ctx, accountID, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if s.worker != nil {
|
|
_, err := s.worker.Enqueue(ctx, TaskTypeConversationDeleteObject, conversationDeleteObjectJob{AccountID: accountID, ConversationID: conversation.ID}, worker.WithQueue("low"), worker.WithMaxAttempts(3), worker.WithIdempotencyKey(fmt.Sprintf("conversation-delete:%d:%d", accountID, conversation.ID)))
|
|
return err
|
|
}
|
|
return s.deleteLoaded(ctx, conversation)
|
|
}
|
|
|
|
func (s *ConversationService) deleteNow(ctx context.Context, accountID, id uint) error {
|
|
conversation, err := s.repo.FindByAccountAndID(ctx, accountID, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.deleteLoaded(ctx, conversation)
|
|
}
|
|
|
|
func (s *ConversationService) deleteLoaded(ctx context.Context, conversation *model.Conversation) error {
|
|
if err := s.repo.Delete(ctx, conversation.ID); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Dispatch EventConversationDeleted
|
|
s.dispatchConversationEvent(ctx, channel.EventConversationDeleted, conversation)
|
|
s.deleteConversationIndex(ctx, conversation.AccountID, conversation.ID)
|
|
|
|
return nil
|
|
}
|
|
|
|
// ListMessages retrieves messages for a conversation.
|
|
func (s *ConversationService) ListMessages(ctx context.Context, conversationID uint, offset, limit int) ([]model.Message, int64, error) {
|
|
return s.msgRepo.FindByConversation(ctx, conversationID, offset, limit)
|
|
}
|
|
|
|
// Mute mutes a conversation.
|
|
func (s *ConversationService) Mute(ctx context.Context, accountID, id uint) (*model.Conversation, error) {
|
|
conversation, err := s.repo.FindByAccountAndID(ctx, accountID, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
db := s.repo.DB().WithContext(ctx)
|
|
if err := db.Transaction(func(tx *gorm.DB) error {
|
|
if conversation.ContactID != 0 {
|
|
if err := tx.Model(&model.Contact{}).
|
|
Where("id = ? AND account_id = ?", conversation.ContactID, accountID).
|
|
Update("blocked", true).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return tx.Model(&model.Conversation{}).
|
|
Where("id = ? AND account_id = ?", conversation.ID, accountID).
|
|
Updates(map[string]any{"status": string(model.ConversationStatusResolved), "muted": true}).Error
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
conversation.Status = string(model.ConversationStatusResolved)
|
|
conversation.Muted = true
|
|
|
|
// Dispatch EventConversationMuted
|
|
s.dispatchConversationEvent(ctx, channel.EventConversationMuted, conversation)
|
|
s.indexConversation(ctx, conversation)
|
|
|
|
return conversation, nil
|
|
}
|
|
|
|
// Unmute unmutes a conversation.
|
|
func (s *ConversationService) Unmute(ctx context.Context, accountID, id uint) (*model.Conversation, error) {
|
|
conversation, err := s.repo.FindByAccountAndID(ctx, accountID, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
db := s.repo.DB().WithContext(ctx)
|
|
if err := db.Transaction(func(tx *gorm.DB) error {
|
|
if conversation.ContactID != 0 {
|
|
if err := tx.Model(&model.Contact{}).
|
|
Where("id = ? AND account_id = ?", conversation.ContactID, accountID).
|
|
Update("blocked", false).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return tx.Model(&model.Conversation{}).
|
|
Where("id = ? AND account_id = ?", conversation.ID, accountID).
|
|
Update("muted", false).Error
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
conversation.Muted = false
|
|
|
|
// Dispatch EventConversationUnmuted
|
|
s.dispatchConversationEvent(ctx, channel.EventConversationUnmuted, conversation)
|
|
s.indexConversation(ctx, conversation)
|
|
|
|
return conversation, nil
|
|
}
|
|
|
|
// FilterParams holds advanced filter criteria for conversations.
|
|
// Reference: Chatwoot ConversationFinder — app/finders/conversation_finder.rb
|
|
// Supports all 7 Chatwoot filter dimensions: status, assignee_type, inbox_id, team_id,
|
|
// labels, conversation_type, and sort_by (with updated_within as an extra filter).
|
|
type FilterParams struct {
|
|
Status string `json:"status,omitempty" form:"status" validate:"omitempty,oneof=open resolved pending snoozed all"`
|
|
Priority string `json:"priority,omitempty" form:"priority" validate:"omitempty,oneof=urgent high medium low none"`
|
|
AssigneeType string `json:"assignee_type,omitempty" form:"assignee_type" validate:"omitempty,oneof=me unassigned assigned all"`
|
|
AssigneeID *uint `json:"assignee_id,omitempty" form:"assignee_id"`
|
|
InboxID *uint `json:"inbox_id,omitempty" form:"inbox_id"`
|
|
InboxIDs []uint `json:"inbox_ids,omitempty" form:"inbox_ids"` // multi-inbox filtering (Chatwoot: inbox_id can be array)
|
|
TeamID *uint `json:"team_id,omitempty" form:"team_id"`
|
|
Labels string `json:"labels,omitempty" form:"labels"`
|
|
Tags string `json:"tags,omitempty" form:"tags"` // custom tags (Chatwoot: same as labels via ActsAsTaggableOn)
|
|
ConversationType string `json:"conversation_type,omitempty" form:"conversation_type" validate:"omitempty,oneof=mention participating unattended"`
|
|
SortBy string `json:"sort_by,omitempty" form:"sort_by" validate:"omitempty,oneof=last_activity_at_asc last_activity_at_desc created_at_asc created_at_desc priority_asc priority_desc waiting_since_asc waiting_since_desc priority_desc_created_at_asc latest sort_on_created_at sort_on_priority sort_on_waiting_since"`
|
|
UpdatedWithin *int `json:"updated_within,omitempty" form:"updated_within"` // seconds
|
|
Query string `json:"q,omitempty" form:"q"` // 1:1 Chatwoot: filter_by_query — search messages ILIKE
|
|
SourceID string `json:"source_id,omitempty" form:"source_id"` // 1:1 Chatwoot: filter_by_source_id — contact_inbox.source_id
|
|
Payload []ConversationFilterCondition `json:"payload,omitempty" form:"-"`
|
|
|
|
// AllowedInboxIDs is populated at runtime — the list of inbox IDs the user can access.
|
|
// Not populated from request params; set by the service based on user permissions.
|
|
AllowedInboxIDs []uint
|
|
}
|
|
|
|
type ConversationFilterCondition struct {
|
|
AttributeKey string `json:"attribute_key"`
|
|
FilterOperator string `json:"filter_operator"`
|
|
Values []any `json:"values"`
|
|
QueryOperator string `json:"query_operator,omitempty"`
|
|
CustomAttributeType string `json:"custom_attribute_type,omitempty"`
|
|
}
|
|
|
|
// FilterResult holds the paginated conversations plus meta counts.
|
|
// 1:1 Chatwoot ConversationFinder#perform response shape:
|
|
// {conversations: [...], count: {mine_count, assigned_count, unassigned_count, all_count}}
|
|
type FilterResult struct {
|
|
Conversations []model.Conversation `json:"conversations"`
|
|
Count FilterCountMeta `json:"count"`
|
|
}
|
|
|
|
type FilterCountMeta struct {
|
|
MineCount int64 `json:"mine_count"`
|
|
AssignedCount int64 `json:"assigned_count"`
|
|
UnassignedCount int64 `json:"unassigned_count"`
|
|
AllCount int64 `json:"all_count"`
|
|
}
|
|
|
|
// Filter retrieves conversations matching advanced filter criteria.
|
|
// Reference: Chatwoot ConversationFinder#perform — filters by assignee_type, status,
|
|
// team_id, labels, conversation_type, and applies sort order with pagination.
|
|
func (s *ConversationService) Filter(ctx context.Context, accountID uint, userID uint, params FilterParams, offset, limit int) (*FilterResult, error) {
|
|
if err := pkgvalidator.ValidateStruct(params); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var conversations []model.Conversation
|
|
|
|
query := s.repo.DB().WithContext(ctx).Model(&model.Conversation{}).Where("conversations.account_id = ?", accountID)
|
|
var err error
|
|
query, err = s.applyConversationPermissionFilter(ctx, accountID, userID, query)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(params.Payload) > 0 {
|
|
advancedQuery, err := s.applyConversationFilterPayload(ctx, query, accountID, params.Payload)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
query = advancedQuery
|
|
}
|
|
|
|
// 1. Filter by status
|
|
if params.Status != "" {
|
|
query = query.Where("status = ?", params.Status)
|
|
}
|
|
|
|
// 2. Filter by priority
|
|
if params.Priority != "" {
|
|
query = query.Where("priority = ?", params.Priority)
|
|
}
|
|
|
|
// 3. Filter by assignee_type (Chatwoot: me/unassigned/assigned/all)
|
|
// When AssigneeID is also set, it takes precedence for "me" type.
|
|
switch params.AssigneeType {
|
|
case "me":
|
|
query = query.Where("assignee_id = ?", userID)
|
|
case "unassigned":
|
|
query = query.Where("assignee_id IS NULL")
|
|
case "assigned":
|
|
query = query.Where("assignee_id IS NOT NULL")
|
|
case "all":
|
|
// no additional filter
|
|
default:
|
|
// If assignee_type is empty but AssigneeID is set, use the ID directly
|
|
if params.AssigneeID != nil {
|
|
if *params.AssigneeID == 0 {
|
|
query = query.Where("assignee_id IS NULL")
|
|
} else {
|
|
query = query.Where("assignee_id = ?", *params.AssigneeID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 4. Filter by inbox_id
|
|
if params.InboxID != nil {
|
|
query = query.Where("inbox_id = ?", *params.InboxID)
|
|
}
|
|
|
|
// 5. Filter by team_id
|
|
if params.TeamID != nil {
|
|
query = query.Where("team_id = ?", *params.TeamID)
|
|
}
|
|
|
|
// 6. Filter by labels — 1:1 Chatwoot: tagged_with(labels, any: true) → OR semantics
|
|
if params.Labels != "" {
|
|
labels := strings.Split(params.Labels, ",")
|
|
conditions := make([]string, len(labels))
|
|
args := make([]interface{}, len(labels))
|
|
for i, label := range labels {
|
|
conditions[i] = "labels LIKE ?"
|
|
args[i] = "%" + strings.TrimSpace(label) + "%"
|
|
}
|
|
query = query.Where(strings.Join(conditions, " OR "), args...)
|
|
}
|
|
|
|
// 7. Filter by conversation_type (Chatwoot: mention/participating/unattended)
|
|
switch params.ConversationType {
|
|
case "mention":
|
|
// Mentioned conversations — subquery on mentions table
|
|
query = query.Where("id IN (SELECT conversation_id FROM mentions WHERE user_id = ?)", userID)
|
|
case "participating":
|
|
// Participating conversations — subquery on participants table
|
|
query = query.Where("id IN (SELECT conversation_id FROM conversation_participants WHERE user_id = ?)", userID)
|
|
case "unattended":
|
|
// Unattended conversations — first_reply_at IS NULL AND assignee_id IS NULL
|
|
query = query.Where("first_reply_at IS NULL AND assignee_id IS NULL")
|
|
}
|
|
|
|
// 8. Filter by updated_within (seconds) — Chatwoot: updated_at > NOW - interval
|
|
if params.UpdatedWithin != nil {
|
|
query = query.Where("updated_at > NOW() - INTERVAL '" + fmt.Sprintf("%d", *params.UpdatedWithin) + " seconds'")
|
|
}
|
|
|
|
// 9. Filter by query (q) — 1:1 Chatwoot: filter_by_query
|
|
// Search messages.content ILIKE, restricted to incoming/outgoing message types
|
|
if params.Query != "" {
|
|
searchClause, searchTerm := conversationMessageSearchClause(query, params.Query)
|
|
query = query.Joins("JOIN messages ON messages.conversation_id = conversations.id").
|
|
Where(searchClause, searchTerm).
|
|
Where("messages.message_type IN ?", []string{string(model.MessageTypeIncoming), string(model.MessageTypeOutgoing)})
|
|
}
|
|
|
|
// 10. Filter by source_id — 1:1 Chatwoot: filter_by_source_id
|
|
// Joins contact_inbox, filters by source_id
|
|
if params.SourceID != "" {
|
|
query = query.Joins("JOIN contact_inboxes ON contact_inboxes.id = conversations.contact_inbox_id").
|
|
Where("contact_inboxes.source_id = ?", params.SourceID)
|
|
}
|
|
|
|
// 9. Sort order — Chatwoot ConversationFinder::SORT_OPTIONS
|
|
orderClause := "last_activity_at DESC" // default: last_activity_at_desc
|
|
switch params.SortBy {
|
|
case "last_activity_at_asc":
|
|
orderClause = "last_activity_at ASC"
|
|
case "last_activity_at_desc", "latest":
|
|
orderClause = "last_activity_at DESC"
|
|
case "created_at_asc", "sort_on_created_at":
|
|
orderClause = "created_at ASC"
|
|
case "created_at_desc":
|
|
orderClause = "created_at DESC"
|
|
case "priority_asc":
|
|
orderClause = "priority ASC"
|
|
case "priority_desc", "sort_on_priority":
|
|
orderClause = "priority DESC"
|
|
case "waiting_since_asc", "sort_on_waiting_since":
|
|
orderClause = "waiting_since ASC"
|
|
case "waiting_since_desc":
|
|
orderClause = "waiting_since DESC"
|
|
}
|
|
|
|
countQuery := query.Session(&gorm.Session{})
|
|
var mineCount, unassignedCount, allCount int64
|
|
if err := countQuery.Session(&gorm.Session{}).Where("assignee_id = ?", userID).Count(&mineCount).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if err := countQuery.Session(&gorm.Session{}).Where("assignee_id IS NULL").Count(&unassignedCount).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if err := countQuery.Session(&gorm.Session{}).Count(&allCount).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
err = query.Offset(offset).Limit(limit).Order(orderClause).Find(&conversations).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &FilterResult{
|
|
Conversations: conversations,
|
|
Count: FilterCountMeta{
|
|
MineCount: mineCount,
|
|
AssignedCount: allCount - unassignedCount,
|
|
UnassignedCount: unassignedCount,
|
|
AllCount: allCount,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func conversationMessageSearchClause(query *gorm.DB, value string) (string, string) {
|
|
term := "%" + value + "%"
|
|
if query != nil && query.Dialector != nil && query.Dialector.Name() != "postgres" {
|
|
return "LOWER(messages.content) LIKE ?", "%" + strings.ToLower(value) + "%"
|
|
}
|
|
return "messages.content ILIKE ?", term
|
|
}
|
|
|
|
func (s *ConversationService) applyConversationPermissionFilter(ctx context.Context, accountID, userID uint, query *gorm.DB) (*gorm.DB, error) {
|
|
if userID == 0 {
|
|
return query, nil
|
|
}
|
|
|
|
var accountUser model.AccountUser
|
|
err := s.repo.DB().WithContext(ctx).Where("account_id = ? AND user_id = ?", accountID, userID).First(&accountUser).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return query.Where("1 = 0"), nil
|
|
}
|
|
if err != nil {
|
|
return query, err
|
|
}
|
|
if accountUser.IsAdministrator() {
|
|
return query, nil
|
|
}
|
|
|
|
inboxIDs, err := s.visibleUnreadCountInboxIDs(ctx, accountID, userID)
|
|
if err != nil {
|
|
return query, err
|
|
}
|
|
if len(inboxIDs) == 0 {
|
|
return query.Where("1 = 0"), nil
|
|
}
|
|
return query.Where("conversations.inbox_id IN ?", inboxIDs), nil
|
|
}
|
|
|
|
func (s *ConversationService) applyConversationFilterPayload(ctx context.Context, query *gorm.DB, accountID uint, payload []ConversationFilterCondition) (*gorm.DB, error) {
|
|
clauses := make([]string, 0, len(payload)*2)
|
|
args := make([]any, 0, len(payload))
|
|
for _, condition := range payload {
|
|
queryOperator := strings.ToUpper(strings.TrimSpace(condition.QueryOperator))
|
|
if queryOperator != "" && queryOperator != "AND" && queryOperator != "OR" {
|
|
return nil, fmt.Errorf("Query operator must be either \"AND\" or \"OR\".")
|
|
}
|
|
|
|
clause, clauseArgs, err := s.conversationFilterClause(ctx, query, accountID, condition)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(clauses) > 0 && !conversationFilterIsQueryOperator(clauses[len(clauses)-1]) {
|
|
clauses = append(clauses, "AND")
|
|
}
|
|
clauses = append(clauses, clause)
|
|
args = append(args, clauseArgs...)
|
|
|
|
if queryOperator != "" {
|
|
clauses = append(clauses, queryOperator)
|
|
}
|
|
}
|
|
if len(clauses) == 0 {
|
|
return query, nil
|
|
}
|
|
last := clauses[len(clauses)-1]
|
|
if conversationFilterIsQueryOperator(last) {
|
|
clauses = clauses[:len(clauses)-1]
|
|
}
|
|
query = query.Where(strings.Join(clauses, " "), args...)
|
|
return query, nil
|
|
}
|
|
|
|
func (s *ConversationService) conversationFilterClause(ctx context.Context, query *gorm.DB, accountID uint, condition ConversationFilterCondition) (string, []any, error) {
|
|
attribute := strings.TrimSpace(condition.AttributeKey)
|
|
operator := strings.TrimSpace(condition.FilterOperator)
|
|
values := conversationFilterStringValues(condition.Values)
|
|
if attribute == "labels" {
|
|
return conversationFilterLabelsClause(accountID, operator, values)
|
|
}
|
|
if attribute == "created_at" || attribute == "last_activity_at" {
|
|
return conversationFilterDateClause(attribute, operator, values)
|
|
}
|
|
|
|
column, allowedOperators, err := conversationFilterColumn(attribute)
|
|
if err == nil {
|
|
return conversationFilterBuildClause(attribute, column, nil, operator, values, allowedOperators, false)
|
|
}
|
|
|
|
if additional, ok := conversationFilterAdditionalAttribute(attribute); ok {
|
|
expr, exprArgs := conversationFilterJSONExtract(query, "conversations.additional_attributes", attribute)
|
|
return conversationFilterBuildClause(attribute, expr, exprArgs, operator, values, additional.allowedOperators, false)
|
|
}
|
|
|
|
def, defErr := s.findConversationFilterCustomAttributeDefinition(ctx, accountID, attribute, condition.CustomAttributeType)
|
|
if defErr != nil {
|
|
return "", nil, defErr
|
|
}
|
|
if def == nil {
|
|
return "", nil, fmt.Errorf("Invalid attribute key - [%s]", attribute)
|
|
}
|
|
|
|
expr, exprArgs := conversationFilterJSONExtract(query, "conversations.custom_attributes", attribute)
|
|
expr = conversationFilterCustomAttributeExpression(expr, def.AttributeType)
|
|
return conversationFilterBuildClause(attribute, expr, exprArgs, operator, conversationFilterCustomAttributeValues(values, def.AttributeType), conversationFilterCustomAttributeOperators(def.AttributeType), true)
|
|
}
|
|
|
|
func conversationFilterIsQueryOperator(value string) bool {
|
|
return value == "AND" || value == "OR"
|
|
}
|
|
|
|
func conversationFilterColumn(attribute string) (string, []string, error) {
|
|
switch attribute {
|
|
case "status":
|
|
return "conversations.status", []string{"equal_to", "not_equal_to"}, nil
|
|
case "priority":
|
|
return "conversations.priority", []string{"equal_to", "not_equal_to"}, nil
|
|
case "assignee_id":
|
|
return "conversations.assignee_id", []string{"equal_to", "not_equal_to", "is_present", "is_not_present"}, nil
|
|
case "inbox_id":
|
|
return "conversations.inbox_id", []string{"equal_to", "not_equal_to", "is_present", "is_not_present"}, nil
|
|
case "team_id":
|
|
return "conversations.team_id", []string{"equal_to", "not_equal_to", "is_present", "is_not_present"}, nil
|
|
case "display_id":
|
|
return "CAST(conversations.display_id AS TEXT)", []string{"equal_to", "not_equal_to", "contains", "does_not_contain"}, nil
|
|
case "campaign_id":
|
|
return "conversations.campaign_id", []string{"equal_to", "not_equal_to", "is_present", "is_not_present"}, nil
|
|
default:
|
|
return "", nil, fmt.Errorf("Invalid attribute key - [%s]", attribute)
|
|
}
|
|
}
|
|
|
|
func conversationFilterLabelsClause(accountID uint, operator string, values []string) (string, []any, error) {
|
|
allowedOperators := []string{"equal_to", "not_equal_to", "is_present", "is_not_present"}
|
|
if !conversationFilterOperatorAllowed(operator, allowedOperators) {
|
|
return "", nil, fmt.Errorf("Invalid operator. The allowed operators for labels are [%s].", strings.Join(allowedOperators, ","))
|
|
}
|
|
if operator != "is_present" && operator != "is_not_present" && len(values) == 0 {
|
|
return "", nil, fmt.Errorf("Invalid value for labels")
|
|
}
|
|
|
|
base := "SELECT 1 FROM conversation_labels JOIN tags ON tags.id = conversation_labels.tag_id WHERE conversation_labels.conversation_id = conversations.id AND conversation_labels.account_id = ? AND tags.account_id = ?"
|
|
args := []any{accountID, accountID}
|
|
switch operator {
|
|
case "equal_to":
|
|
args = append(args, values)
|
|
return "EXISTS (" + base + " AND tags.name IN ?)", args, nil
|
|
case "not_equal_to":
|
|
args = append(args, values)
|
|
return "NOT EXISTS (" + base + " AND tags.name IN ?)", args, nil
|
|
case "is_present":
|
|
return "EXISTS (" + base + ")", args, nil
|
|
case "is_not_present":
|
|
return "NOT EXISTS (" + base + ")", args, nil
|
|
default:
|
|
return "", nil, fmt.Errorf("Invalid operator. The allowed operators for labels are [%s].", strings.Join(allowedOperators, ","))
|
|
}
|
|
}
|
|
|
|
func conversationFilterDateClause(attribute, operator string, values []string) (string, []any, error) {
|
|
allowedOperators := []string{"is_greater_than", "is_less_than", "days_before"}
|
|
if !conversationFilterOperatorAllowed(operator, allowedOperators) {
|
|
return "", nil, fmt.Errorf("Invalid operator. The allowed operators for %s are [%s].", attribute, strings.Join(allowedOperators, ","))
|
|
}
|
|
if len(values) == 0 {
|
|
return "", nil, fmt.Errorf("Invalid value for %s", attribute)
|
|
}
|
|
|
|
value, err := conversationFilterDateValue(attribute, operator, values[0])
|
|
if err != nil {
|
|
return "", nil, fmt.Errorf("Invalid value for %s", attribute)
|
|
}
|
|
comparison := ">"
|
|
if operator == "is_less_than" || operator == "days_before" {
|
|
comparison = "<"
|
|
}
|
|
if attribute == "last_activity_at" {
|
|
return "conversations.last_activity_at " + comparison + " ?", []any{value.Unix()}, nil
|
|
}
|
|
return "conversations.created_at " + comparison + " ?", []any{value}, nil
|
|
}
|
|
|
|
func conversationFilterDateValue(attribute, operator, value string) (time.Time, error) {
|
|
if operator == "days_before" {
|
|
days, err := strconv.Atoi(strings.TrimSpace(value))
|
|
if err != nil {
|
|
return time.Time{}, err
|
|
}
|
|
today := time.Now().UTC().Truncate(24 * time.Hour)
|
|
return today.AddDate(0, 0, -days), nil
|
|
}
|
|
if parsed, err := time.Parse(time.RFC3339, value); err == nil {
|
|
return parsed, nil
|
|
}
|
|
if parsed, err := time.Parse("2006-01-02", value); err == nil {
|
|
return parsed, nil
|
|
}
|
|
if attribute == "last_activity_at" {
|
|
seconds, err := strconv.ParseInt(value, 10, 64)
|
|
if err == nil {
|
|
return time.Unix(seconds, 0).UTC(), nil
|
|
}
|
|
}
|
|
return time.Time{}, fmt.Errorf("invalid date")
|
|
}
|
|
|
|
type conversationAdditionalFilter struct {
|
|
allowedOperators []string
|
|
}
|
|
|
|
func conversationFilterAdditionalAttribute(attribute string) (conversationAdditionalFilter, bool) {
|
|
switch attribute {
|
|
case "browser_language", "conversation_language":
|
|
return conversationAdditionalFilter{allowedOperators: []string{"equal_to", "not_equal_to"}}, true
|
|
case "referer", "mail_subject":
|
|
return conversationAdditionalFilter{allowedOperators: []string{"equal_to", "not_equal_to", "contains", "does_not_contain"}}, true
|
|
default:
|
|
return conversationAdditionalFilter{}, false
|
|
}
|
|
}
|
|
|
|
func (s *ConversationService) findConversationFilterCustomAttributeDefinition(ctx context.Context, accountID uint, attribute, customAttributeType string) (*model.CustomAttributeDefinition, error) {
|
|
attributeModel := strings.TrimSpace(customAttributeType)
|
|
if attributeModel == "" {
|
|
attributeModel = "conversation_attribute"
|
|
}
|
|
if attributeModel == "conversation" {
|
|
attributeModel = "conversation_attribute"
|
|
}
|
|
if attributeModel != "conversation_attribute" {
|
|
return nil, fmt.Errorf("Invalid attribute key - [%s]", attribute)
|
|
}
|
|
|
|
var def model.CustomAttributeDefinition
|
|
err := s.repo.DB().WithContext(ctx).
|
|
Where("account_id = ? AND attribute_name = ? AND attribute_model IN ?", accountID, attribute, []string{"conversation_attribute", "conversation"}).
|
|
First(&def).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &def, nil
|
|
}
|
|
|
|
func conversationFilterBuildClause(attribute, expression string, expressionArgs []any, operator string, values []string, allowedOperators []string, includeNullForNotEqual bool) (string, []any, error) {
|
|
if !conversationFilterOperatorAllowed(operator, allowedOperators) {
|
|
return "", nil, fmt.Errorf("Invalid operator. The allowed operators for %s are [%s].", attribute, strings.Join(allowedOperators, ","))
|
|
}
|
|
if operator == "is_present" {
|
|
return expression + " IS NOT NULL", append([]any{}, expressionArgs...), nil
|
|
}
|
|
if operator == "is_not_present" {
|
|
return expression + " IS NULL", append([]any{}, expressionArgs...), nil
|
|
}
|
|
if len(values) == 0 {
|
|
return "", nil, fmt.Errorf("Invalid value for %s", attribute)
|
|
}
|
|
|
|
args := append([]any{}, expressionArgs...)
|
|
switch operator {
|
|
case "equal_to":
|
|
args = append(args, values)
|
|
return expression + " IN ?", args, nil
|
|
case "not_equal_to":
|
|
args = append(args, values)
|
|
if includeNullForNotEqual {
|
|
args = append(args, expressionArgs...)
|
|
return "(" + expression + " NOT IN ? OR " + expression + " IS NULL)", args, nil
|
|
}
|
|
return expression + " NOT IN ?", args, nil
|
|
case "contains":
|
|
clause, likeArgs := conversationFilterLikeClause(expression, expressionArgs, values, false)
|
|
return clause, likeArgs, nil
|
|
case "does_not_contain":
|
|
clause, likeArgs := conversationFilterLikeClause(expression, expressionArgs, values, true)
|
|
return clause, likeArgs, nil
|
|
default:
|
|
return "", nil, fmt.Errorf("Invalid operator. The allowed operators for %s are [%s].", attribute, strings.Join(allowedOperators, ","))
|
|
}
|
|
}
|
|
|
|
func conversationFilterLikeClause(expression string, expressionArgs []any, values []string, negate bool) (string, []any) {
|
|
clauses := make([]string, 0, len(values))
|
|
args := make([]any, 0, len(values)*(len(expressionArgs)+1))
|
|
operator := "LIKE"
|
|
joiner := " OR "
|
|
if negate {
|
|
operator = "NOT LIKE"
|
|
joiner = " AND "
|
|
}
|
|
for _, value := range values {
|
|
clauses = append(clauses, "LOWER("+expression+") "+operator+" ?")
|
|
args = append(args, expressionArgs...)
|
|
args = append(args, "%"+strings.ToLower(value)+"%")
|
|
}
|
|
return "(" + strings.Join(clauses, joiner) + ")", args
|
|
}
|
|
|
|
func conversationFilterJSONExtract(query *gorm.DB, column, key string) (string, []any) {
|
|
dialect := ""
|
|
if query != nil && query.Dialector != nil {
|
|
dialect = query.Dialector.Name()
|
|
}
|
|
switch dialect {
|
|
case "sqlite":
|
|
return "json_extract(" + column + ", ?)", []any{"$." + key}
|
|
case "mysql":
|
|
return "JSON_UNQUOTE(JSON_EXTRACT(" + column + ", ?))", []any{"$." + key}
|
|
default:
|
|
return column + " ->> ?", []any{key}
|
|
}
|
|
}
|
|
|
|
func conversationFilterCustomAttributeOperators(attributeType string) []string {
|
|
switch strings.TrimSpace(attributeType) {
|
|
case "text", "link", "list", "":
|
|
return []string{"equal_to", "not_equal_to", "contains", "does_not_contain"}
|
|
default:
|
|
return []string{"equal_to", "not_equal_to"}
|
|
}
|
|
}
|
|
|
|
func conversationFilterCustomAttributeValues(values []string, attributeType string) []string {
|
|
switch strings.TrimSpace(attributeType) {
|
|
case "text", "link", "list", "":
|
|
lowered := make([]string, 0, len(values))
|
|
for _, value := range values {
|
|
lowered = append(lowered, strings.ToLower(value))
|
|
}
|
|
return lowered
|
|
default:
|
|
return values
|
|
}
|
|
}
|
|
|
|
func conversationFilterCustomAttributeExpression(expression, attributeType string) string {
|
|
switch strings.TrimSpace(attributeType) {
|
|
case "text", "link", "list", "":
|
|
return "LOWER(" + expression + ")"
|
|
default:
|
|
return expression
|
|
}
|
|
}
|
|
|
|
func conversationFilterOperatorAllowed(operator string, allowed []string) bool {
|
|
for _, value := range allowed {
|
|
if operator == value {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func conversationFilterStringValues(values []any) []string {
|
|
result := make([]string, 0, len(values))
|
|
for _, value := range values {
|
|
text := strings.TrimSpace(fmt.Sprintf("%v", value))
|
|
if text != "" {
|
|
result = append(result, text)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// ListWithFinder retrieves conversations for the Chatwoot index/search finder contract.
|
|
// Reference: Chatwoot ConversationFinder#perform.
|
|
func (s *ConversationService) ListWithFinder(ctx context.Context, accountID, userID uint, params FilterParams, offset, limit int) (*FilterResult, error) {
|
|
if err := pkgvalidator.ValidateStruct(params); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
metaParams, err := s.conversationMetaParamsForUser(ctx, accountID, userID, params)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
meta, err := s.repo.GetMeta(ctx, accountID, userID, metaParams)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
conversations, err := s.repo.ListForFinder(ctx, accountID, userID, repository.ConversationFinderListParams{
|
|
ConversationMetaParams: metaParams,
|
|
AssigneeType: params.AssigneeType,
|
|
SortBy: params.SortBy,
|
|
UpdatedWithin: params.UpdatedWithin,
|
|
}, offset, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &FilterResult{
|
|
Conversations: conversations,
|
|
Count: FilterCountMeta{
|
|
MineCount: meta.MineCount,
|
|
AssignedCount: meta.AssignedCount,
|
|
UnassignedCount: meta.UnassignedCount,
|
|
AllCount: meta.AllCount,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// Search searches conversations by label or metadata within an account.
|
|
func (s *ConversationService) Search(ctx context.Context, accountID uint, query string, offset, limit int, searchMode search.SearchMode) ([]model.Conversation, int64, error) {
|
|
return s.repo.Search(ctx, accountID, query, offset, limit, searchMode)
|
|
}
|
|
|
|
// UpdatePriority updates the priority of a conversation.
|
|
func (s *ConversationService) UpdatePriority(ctx context.Context, accountID, id uint, priority string) (*model.Conversation, error) {
|
|
if priority == "" {
|
|
priority = "none"
|
|
}
|
|
newPriority := model.ConversationPriority(priority)
|
|
if newPriority != model.ConversationPriorityUrgent &&
|
|
newPriority != model.ConversationPriorityHigh &&
|
|
newPriority != model.ConversationPriorityMedium &&
|
|
newPriority != model.ConversationPriorityLow &&
|
|
priority != "none" {
|
|
return nil, errors.New("invalid priority value")
|
|
}
|
|
|
|
conversation, err := s.repo.FindByAccountAndID(ctx, accountID, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
oldPriority := conversation.Priority
|
|
|
|
conversation.Priority = string(newPriority)
|
|
if err := s.repo.Update(ctx, conversation); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
changes := changedAttributes(map[string][2]interface{}{"priority": {oldPriority, conversation.Priority}})
|
|
changeData := eventDataWithChanges(changes)
|
|
|
|
// Dispatch Chatwoot automation-visible update plus local priority event.
|
|
s.dispatchConversationEventWithData(ctx, channel.EventConversationUpdated, conversation, changeData)
|
|
s.dispatchConversationEventWithData(ctx, channel.EventConversationPriorityUpdated, conversation, changeData)
|
|
s.indexConversation(ctx, conversation)
|
|
|
|
return conversation, nil
|
|
}
|
|
|
|
// GetMeta retrieves Chatwoot conversation sidebar counts for an account.
|
|
// Reference: ConversationFinder#perform_meta_only and conversations/meta.json.jbuilder.
|
|
func (s *ConversationService) GetMeta(ctx context.Context, accountID, userID uint, params FilterParams) (*repository.ConversationMeta, error) {
|
|
metaParams, err := s.conversationMetaParamsForUser(ctx, accountID, userID, params)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return s.repo.GetMeta(ctx, accountID, userID, metaParams)
|
|
}
|
|
|
|
func (s *ConversationService) conversationMetaParamsForUser(ctx context.Context, accountID, userID uint, params FilterParams) (repository.ConversationMetaParams, error) {
|
|
metaParams := repository.ConversationMetaParams{
|
|
Status: params.Status,
|
|
InboxID: params.InboxID,
|
|
TeamID: params.TeamID,
|
|
Labels: splitConversationMetaLabels(params.Labels),
|
|
ConversationType: params.ConversationType,
|
|
Query: params.Query,
|
|
SourceID: params.SourceID,
|
|
}
|
|
|
|
if userID != 0 {
|
|
var accountUser model.AccountUser
|
|
err := s.repo.DB().WithContext(ctx).Where("account_id = ? AND user_id = ?", accountID, userID).First(&accountUser).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
metaParams.RestrictToInboxes = true
|
|
return metaParams, nil
|
|
}
|
|
if err != nil {
|
|
return metaParams, err
|
|
}
|
|
if !accountUser.IsAdministrator() {
|
|
ids, err := s.visibleUnreadCountInboxIDs(ctx, accountID, userID)
|
|
if err != nil {
|
|
return metaParams, err
|
|
}
|
|
metaParams.RestrictToInboxes = true
|
|
metaParams.RestrictedInboxIDs = ids
|
|
}
|
|
}
|
|
|
|
return metaParams, nil
|
|
}
|
|
|
|
func splitConversationMetaLabels(labels string) []string {
|
|
if strings.TrimSpace(labels) == "" {
|
|
return nil
|
|
}
|
|
parts := strings.Split(labels, ",")
|
|
out := make([]string, 0, len(parts))
|
|
for _, part := range parts {
|
|
part = strings.TrimSpace(part)
|
|
if part != "" {
|
|
out = append(out, part)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// MarkUnread marks a conversation as unread by setting agent_last_seen_at to
|
|
// (last_incoming_message.created_at - 1 second), matching Chatwoot behavior.
|
|
// If no incoming messages exist, agent_last_seen_at is set to nil.
|
|
// Reference: Chatwoot app/controllers/api/v1/conversations_controller.rb#unread
|
|
func (s *ConversationService) MarkUnread(ctx context.Context, accountID, id uint) (*model.Conversation, error) {
|
|
conversation, err := s.repo.FindByAccountAndID(ctx, accountID, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Find the last incoming message for this conversation
|
|
lastIncomingMsg, err := s.msgRepo.FindLastIncomingByConversation(ctx, id)
|
|
if err != nil {
|
|
// No incoming message found; clear both last-seen columns like Chatwoot's update_last_seen_on_conversation(nil, true).
|
|
if err := s.updateLastSeenColumns(ctx, accountID, id, nil, true); err != nil {
|
|
return nil, err
|
|
}
|
|
} else {
|
|
// Set both last-seen columns to last_incoming_message.CreatedAt - 1 second.
|
|
lastSeenAt := lastIncomingMsg.CreatedAt.Add(-1 * time.Second).Unix()
|
|
if err := s.updateLastSeenColumns(ctx, accountID, id, &lastSeenAt, true); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// Re-fetch to get updated state
|
|
conversation, err = s.repo.FindByAccountAndID(ctx, accountID, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.indexConversation(ctx, conversation)
|
|
|
|
return conversation, nil
|
|
}
|
|
|
|
// SendTranscript sends a conversation transcript via email.
|
|
// Reference: Chatwoot app/controllers/api/v1/conversations_controller.rb#transcript
|
|
func (s *ConversationService) SendTranscript(ctx context.Context, accountID, conversationID uint, email string) error {
|
|
recipient := strings.TrimSpace(email)
|
|
if recipient == "" {
|
|
return errors.New("email address is required for transcript")
|
|
}
|
|
|
|
conversation, err := s.repo.FindByAccountAndID(ctx, accountID, conversationID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var account model.Account
|
|
if err := s.repo.DB().WithContext(ctx).First(&account, accountID).Error; err != nil {
|
|
return err
|
|
}
|
|
now := time.Now()
|
|
if !account.EmailTranscriptEnabled() {
|
|
return ErrEmailTranscriptDisabled
|
|
}
|
|
if limit := account.EmailRateLimit(); limit > 0 && account.EmailsSentToday(now) >= limit {
|
|
return ErrEmailRateLimited
|
|
}
|
|
|
|
subject, body, err := s.buildTranscriptEmail(ctx, accountID, conversation)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if s.transcriptMailer != nil {
|
|
_, err = s.transcriptMailer.DeliverTranscript(ctx, automation.AutomationTranscriptRequest{
|
|
AccountID: accountID,
|
|
ConversationID: conversation.ID,
|
|
Recipient: recipient,
|
|
Subject: subject,
|
|
Body: body,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := account.IncrementEmailSentCount(now); err != nil {
|
|
return err
|
|
}
|
|
if err := s.repo.DB().WithContext(ctx).Model(&model.Account{}).Where("id = ?", account.ID).Update("custom_attributes", account.CustomAttributes).Error; err != nil {
|
|
return err
|
|
}
|
|
applogger.L().Infof("Transcript request: conversation=%d, account=%d, email=%s, contact=%d",
|
|
conversation.ID, conversation.AccountID, recipient, conversation.ContactID)
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *ConversationService) buildTranscriptEmail(ctx context.Context, accountID uint, conversation *model.Conversation) (string, string, error) {
|
|
var messages []model.Message
|
|
if err := s.repo.DB().WithContext(ctx).
|
|
Where("conversation_id = ? AND account_id = ? AND private = ? AND message_type IN ?", conversation.ID, accountID, false, []string{string(model.MessageTypeIncoming), string(model.MessageTypeOutgoing)}).
|
|
Order("id ASC").
|
|
Find(&messages).Error; err != nil {
|
|
return "", "", err
|
|
}
|
|
|
|
displayID := conversation.ID
|
|
if conversation.DisplayID != nil && *conversation.DisplayID > 0 {
|
|
displayID = *conversation.DisplayID
|
|
}
|
|
subject := fmt.Sprintf("[#%d] Conversation Transcript", displayID)
|
|
var body strings.Builder
|
|
body.WriteString(fmt.Sprintf("Conversation #%d transcript\n\n", displayID))
|
|
for _, message := range messages {
|
|
if strings.TrimSpace(message.Content) == "" {
|
|
continue
|
|
}
|
|
body.WriteString(fmt.Sprintf("[%s] %s\n", message.MessageType, message.Content))
|
|
}
|
|
return subject, body.String(), nil
|
|
}
|
|
|
|
// UpdateCustomAttributes updates the custom attributes of a conversation.
|
|
// Reference: Chatwoot app/controllers/api/v1/conversations_controller.rb#custom_attributes
|
|
func (s *ConversationService) UpdateCustomAttributes(ctx context.Context, accountID, id uint, attrs datatypes.JSON) (*model.Conversation, error) {
|
|
conversation, err := s.repo.FindByAccountAndID(ctx, accountID, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := s.repo.UpdateCustomAttributes(ctx, id, attrs); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Re-fetch to get updated state
|
|
conversation, err = s.repo.FindByAccountAndID(ctx, accountID, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.indexConversation(ctx, conversation)
|
|
|
|
return conversation, nil
|
|
}
|
|
|
|
// UnreadCountsPayload holds the unread conversation counts payload response.
|
|
// Reference: Chatwoot app/controllers/api/v1/accounts/conversations/unread_counts_controller.rb
|
|
type UnreadCountsPayload struct {
|
|
Inboxes map[uint]int64 `json:"inboxes"`
|
|
Labels map[uint]int64 `json:"labels"`
|
|
Teams map[uint]int64 `json:"teams"`
|
|
}
|
|
|
|
var ErrConversationUnreadCountsFeatureNotEnabled = errors.New("Conversation unread counts feature not enabled for this account")
|
|
var ErrEmailTranscriptDisabled = errors.New("Email transcript is not available on your plan")
|
|
var ErrEmailRateLimited = errors.New("email transcript rate limit exceeded")
|
|
|
|
// GetUnreadCounts returns unread conversation counts grouped by inbox, label, and team.
|
|
// Reference: Chatwoot app/services/conversations/unread_counts/counter.rb
|
|
func (s *ConversationService) GetUnreadCounts(ctx context.Context, accountID, userID uint) (*UnreadCountsPayload, error) {
|
|
if s == nil || s.repo == nil || s.repo.DB() == nil {
|
|
return nil, errors.New("conversation service not ready")
|
|
}
|
|
var account model.Account
|
|
if err := s.repo.DB().WithContext(ctx).Select("id", "feature_flags").First(&account, accountID).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if !featureFlagStringEnabled(account.FeatureFlags, "conversation_unread_counts") {
|
|
return nil, ErrConversationUnreadCountsFeatureNotEnabled
|
|
}
|
|
permissionMode, err := s.unreadCountsPermissionMode(ctx, accountID, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if permissionMode == "none" {
|
|
return emptyUnreadCountsPayload(), nil
|
|
}
|
|
inboxIDs, err := s.visibleUnreadCountInboxIDs(ctx, accountID, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
teamIDs, err := s.visibleUnreadCountTeamIDs(ctx, accountID, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
inboxCounts, err := s.repo.GetUnreadCountsByInbox(ctx, accountID, inboxIDs, permissionMode, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
teamCounts, err := s.repo.GetUnreadCountsByTeam(ctx, accountID, inboxIDs, teamIDs, permissionMode, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
labelCounts, err := s.repo.GetUnreadCountsByLabel(ctx, accountID, inboxIDs, permissionMode, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
inboxes := make(map[uint]int64)
|
|
for _, r := range inboxCounts {
|
|
inboxes[r.Key] = r.Count
|
|
}
|
|
|
|
teams := make(map[uint]int64)
|
|
for _, r := range teamCounts {
|
|
teams[r.Key] = r.Count
|
|
}
|
|
|
|
labels := make(map[uint]int64)
|
|
for _, r := range labelCounts {
|
|
labels[r.Key] = r.Count
|
|
}
|
|
|
|
return &UnreadCountsPayload{
|
|
Inboxes: inboxes,
|
|
Labels: labels,
|
|
Teams: teams,
|
|
}, nil
|
|
}
|
|
|
|
func emptyUnreadCountsPayload() *UnreadCountsPayload {
|
|
return &UnreadCountsPayload{Inboxes: map[uint]int64{}, Labels: map[uint]int64{}, Teams: map[uint]int64{}}
|
|
}
|
|
|
|
func (s *ConversationService) unreadCountsPermissionMode(ctx context.Context, accountID, userID uint) (string, error) {
|
|
if userID == 0 {
|
|
return "base", nil
|
|
}
|
|
var accountUser model.AccountUser
|
|
err := s.repo.DB().WithContext(ctx).Preload("CustomRole").Where("account_id = ? AND user_id = ?", accountID, userID).First(&accountUser).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return "none", nil
|
|
}
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if !accountUser.IsAgent() || !accountUser.HasCustomRole() {
|
|
return "base", nil
|
|
}
|
|
if accountUser.CustomRole == nil {
|
|
return "none", nil
|
|
}
|
|
permissions, err := accountUser.CustomRole.GetPermissionKeys()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if hasUnreadCountPermission(permissions, model.DimensionConversationManage) {
|
|
return "base", nil
|
|
}
|
|
if hasUnreadCountPermission(permissions, model.DimensionConversationUnassignedManage) {
|
|
return "unassigned_and_mine", nil
|
|
}
|
|
if hasUnreadCountPermission(permissions, model.DimensionConversationParticipatingManage) {
|
|
return "mine", nil
|
|
}
|
|
return "none", nil
|
|
}
|
|
|
|
func hasUnreadCountPermission(permissions []model.PermissionDimension, permission model.PermissionDimension) bool {
|
|
for _, item := range permissions {
|
|
if item == permission {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (s *ConversationService) visibleUnreadCountInboxIDs(ctx context.Context, accountID, userID uint) ([]uint, error) {
|
|
var ids []uint
|
|
query := s.repo.DB().WithContext(ctx).Model(&model.Inbox{}).Where("account_id = ?", accountID)
|
|
if userID != 0 {
|
|
var accountUser model.AccountUser
|
|
err := s.repo.DB().WithContext(ctx).Where("account_id = ? AND user_id = ?", accountID, userID).First(&accountUser).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return ids, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !accountUser.IsAdministrator() {
|
|
query = query.Joins("INNER JOIN inbox_members ON inbox_members.inbox_id = inboxes.id").Where("inbox_members.user_id = ?", userID)
|
|
}
|
|
}
|
|
if err := query.Pluck("inboxes.id", &ids).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return ids, nil
|
|
}
|
|
|
|
func (s *ConversationService) visibleUnreadCountTeamIDs(ctx context.Context, accountID, userID uint) ([]uint, error) {
|
|
var ids []uint
|
|
query := s.repo.DB().WithContext(ctx).Model(&model.Team{}).Where("account_id = ?", accountID)
|
|
if userID != 0 {
|
|
var accountUser model.AccountUser
|
|
err := s.repo.DB().WithContext(ctx).Where("account_id = ? AND user_id = ?", accountID, userID).First(&accountUser).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return ids, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !accountUser.IsAdministrator() {
|
|
query = query.Joins("INNER JOIN team_members ON team_members.team_id = teams.id").Where("team_members.user_id = ?", userID)
|
|
}
|
|
}
|
|
if err := query.Pluck("teams.id", &ids).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return ids, nil
|
|
}
|
|
|
|
// ToggleTyping broadcasts a typing status event for an agent in a conversation.
|
|
// Reference: Chatwoot app/controllers/api/v1/accounts/conversations_controller.rb #toggle_typing_status
|
|
func (s *ConversationService) ToggleTyping(ctx context.Context, accountID, conversationID, userID uint, typingStatus string, isPrivate bool) error {
|
|
conversation, err := s.GetByAccountAndID(ctx, accountID, conversationID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
eventType, ok := conversationTypingEventType(typingStatus)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
event := channel.NewChannelEvent(eventType, channel.ChannelAPI, accountID, conversation.InboxID)
|
|
event.ConversationID = conversationID
|
|
event.ContactID = conversation.ContactID
|
|
event.UserID = userID
|
|
event.Data["typing_status"] = typingStatus
|
|
event.Data["is_private"] = isPrivate
|
|
s.dispatcher.Dispatch(ctx, event)
|
|
return nil
|
|
}
|
|
|
|
func conversationTypingEventType(status string) (channel.EventType, bool) {
|
|
switch strings.TrimSpace(status) {
|
|
case "on", "typing_on":
|
|
return channel.EventConversationTypingOn, true
|
|
case "off", "typing_off":
|
|
return channel.EventConversationTypingOff, true
|
|
default:
|
|
return "", false
|
|
}
|
|
}
|
|
|
|
// UpdateLastSeen marks the current user as having viewed a conversation.
|
|
// Reference: Chatwoot conversations_controller.rb #update_last_seen
|
|
func (s *ConversationService) UpdateLastSeen(ctx context.Context, accountID, conversationID, userID uint) error {
|
|
conversation, err := s.GetByAccountAndID(ctx, accountID, conversationID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if userID != 0 {
|
|
now := time.Now()
|
|
if err := s.repo.DB().WithContext(ctx).Model(&model.Notification{}).
|
|
Where("user_id = ? AND account_id = ? AND primary_actor_type = ? AND primary_actor_id = ? AND read_at IS NULL", userID, accountID, "Conversation", conversation.ID).
|
|
Update("read_at", now).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
isAssignee := userID != 0 && conversation.AssigneeID != nil && *conversation.AssigneeID == userID
|
|
nowTS := time.Now().Unix()
|
|
if isAssignee {
|
|
hasUnread, err := s.hasMessagesSince(ctx, conversation, conversation.AssigneeLastSeenAt)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if hasUnread {
|
|
return s.updateLastSeenColumns(ctx, accountID, conversation.ID, &nowTS, true)
|
|
}
|
|
} else {
|
|
hasUnread, err := s.hasMessagesSince(ctx, conversation, conversation.AgentLastSeenAt)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if hasUnread {
|
|
return s.updateLastSeenColumns(ctx, accountID, conversation.ID, &nowTS, false)
|
|
}
|
|
}
|
|
|
|
if !shouldUpdateConversationLastSeen(conversation, isAssignee, nowTS) {
|
|
return nil
|
|
}
|
|
return s.updateLastSeenColumns(ctx, accountID, conversation.ID, &nowTS, isAssignee)
|
|
}
|
|
|
|
func (s *ConversationService) hasMessagesSince(ctx context.Context, conversation *model.Conversation, seenAt *int64) (bool, error) {
|
|
query := s.repo.DB().WithContext(ctx).Model(&model.Message{}).
|
|
Where("account_id = ? AND conversation_id = ?", conversation.AccountID, conversation.ID)
|
|
if seenAt != nil {
|
|
query = query.Where("created_at > ?", time.Unix(*seenAt, 0))
|
|
}
|
|
var count int64
|
|
if err := query.Count(&count).Error; err != nil {
|
|
return false, err
|
|
}
|
|
return count > 0, nil
|
|
}
|
|
|
|
func shouldUpdateConversationLastSeen(conversation *model.Conversation, isAssignee bool, nowTS int64) bool {
|
|
agentNeedsUpdate := conversation.AgentLastSeenAt == nil || *conversation.AgentLastSeenAt < nowTS-int64(time.Hour/time.Second)
|
|
if !isAssignee {
|
|
return agentNeedsUpdate
|
|
}
|
|
assigneeNeedsUpdate := conversation.AssigneeLastSeenAt == nil || *conversation.AssigneeLastSeenAt < nowTS-int64(time.Hour/time.Second)
|
|
return agentNeedsUpdate || assigneeNeedsUpdate
|
|
}
|
|
|
|
func (s *ConversationService) updateLastSeenColumns(ctx context.Context, accountID, conversationID uint, lastSeenAt *int64, updateAssignee bool) error {
|
|
updates := map[string]any{"agent_last_seen_at": lastSeenAt}
|
|
if updateAssignee {
|
|
updates["assignee_last_seen_at"] = lastSeenAt
|
|
}
|
|
return s.repo.DB().WithContext(ctx).Model(&model.Conversation{}).
|
|
Where("id = ? AND account_id = ?", conversationID, accountID).
|
|
UpdateColumns(updates).Error
|
|
}
|
|
|
|
// AssignTeam assigns a team (and optionally a specific agent) to a conversation.
|
|
// Reference: Chatwoot app/controllers/api/v1/accounts/conversations/assignments_controller.rb
|
|
func (s *ConversationService) AssignTeam(ctx context.Context, accountID, conversationID uint, agentID *uint, teamID *uint) (*model.Conversation, error) {
|
|
conversation, err := s.GetByAccountAndID(ctx, accountID, conversationID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// === Team validation ===
|
|
// Reference: Chatwoot AssignmentsController#set_team — validates team belongs to account
|
|
if teamID != nil && *teamID != 0 {
|
|
if s.teamRepo != nil {
|
|
team, err := s.teamRepo.FindByIDAndAccount(ctx, *teamID, accountID)
|
|
if err != nil || team == nil {
|
|
return nil, errors.New("team not found in this account")
|
|
}
|
|
// Check team auto-assignment eligibility
|
|
if !team.AllowAutoAssignment && agentID == nil {
|
|
return nil, errors.New("team does not allow auto-assignment")
|
|
}
|
|
}
|
|
conversation.TeamID = teamID
|
|
}
|
|
|
|
// === Agent authorization ===
|
|
// Reference: Chatwoot AssignmentService — validates assignee is account member + inbox member
|
|
if agentID != nil && *agentID != 0 {
|
|
// Role check: must be agent/admin in this account
|
|
if s.accountUserRepo != nil {
|
|
isMember, err := s.accountUserRepo.IsAgentOrAdmin(ctx, accountID, *agentID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to check assignee role: %w", err)
|
|
}
|
|
if !isMember {
|
|
return nil, errors.New("assignee is not an agent or administrator in this account")
|
|
}
|
|
}
|
|
// Inbox membership check
|
|
if s.inboxMemberSvc != nil && !s.inboxMemberSvc.IsMemberOfInbox(ctx, conversation.InboxID, *agentID) {
|
|
return nil, errors.New("assignee is not a member of the conversation's inbox")
|
|
}
|
|
if err := s.ensureAssigneeHasInboxCapacity(ctx, accountID, conversation, *agentID); err != nil {
|
|
return nil, err
|
|
}
|
|
conversation.AssigneeID = agentID
|
|
}
|
|
|
|
// === Team overflow logic ===
|
|
// Reference: Chatwoot CapacityService — when all team members are offline/busy,
|
|
// fall back to account-level online agents
|
|
if teamID != nil && *teamID != 0 && (agentID == nil || *agentID == 0) {
|
|
if s.teamMemberRepo != nil {
|
|
teamMembers, err := s.teamMemberRepo.FindByTeam(ctx, *teamID)
|
|
if err != nil {
|
|
applogger.L().Errorf("failed to get team members for overflow check: %v", err)
|
|
} else {
|
|
// Check if any team member is online
|
|
onlineMemberFound := false
|
|
for _, tm := range teamMembers {
|
|
if tm.AvailabilityStatus == "online" {
|
|
onlineMemberFound = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !onlineMemberFound && len(teamMembers) > 0 {
|
|
// Overflow: all team members offline — fall back to account online agents
|
|
applogger.L().Infof("team %d has no online members, falling back to account %d online agents", *teamID, accountID)
|
|
if s.accountUserRepo != nil {
|
|
onlineAgents, err := s.accountUserRepo.FindOnlineAgentsByAccount(ctx, accountID)
|
|
if err != nil {
|
|
applogger.L().Errorf("failed to find online agents for overflow: %v", err)
|
|
} else if len(onlineAgents) > 0 {
|
|
// Assign first available online agent as overflow
|
|
for _, onlineAgent := range onlineAgents {
|
|
fallbackID := onlineAgent.UserID
|
|
if err := s.ensureAssigneeHasInboxCapacity(ctx, accountID, conversation, fallbackID); err != nil {
|
|
applogger.L().Infof("overflow skipped agent %d due to capacity: %v", fallbackID, err)
|
|
continue
|
|
}
|
|
conversation.AssigneeID = &fallbackID
|
|
applogger.L().Infof("overflow assigned agent %d from account %d online pool", fallbackID, accountID)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := s.repo.Update(ctx, conversation); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
s.dispatchConversationEvent(ctx, channel.EventConversationAssigned, conversation)
|
|
s.indexConversation(ctx, conversation)
|
|
return conversation, nil
|
|
}
|
|
|
|
func (s *ConversationService) ensureAssigneeHasInboxCapacity(ctx context.Context, accountID uint, conversation *model.Conversation, assigneeID uint) error {
|
|
if conversation == nil || conversation.AssigneeID != nil && *conversation.AssigneeID == assigneeID {
|
|
return nil
|
|
}
|
|
db := s.DB()
|
|
if db == nil {
|
|
return nil
|
|
}
|
|
|
|
var accountUser model.AccountUser
|
|
if err := db.WithContext(ctx).
|
|
Where("account_id = ? AND user_id = ?", accountID, assigneeID).
|
|
First(&accountUser).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return errors.New("assignee is not an agent or administrator in this account")
|
|
}
|
|
return fmt.Errorf("load assignee capacity policy: %w", err)
|
|
}
|
|
if accountUser.AgentCapacityPolicyID == nil {
|
|
return nil
|
|
}
|
|
|
|
var limit model.InboxCapacityLimit
|
|
if err := db.WithContext(ctx).
|
|
Where("agent_capacity_policy_id = ? AND inbox_id = ?", *accountUser.AgentCapacityPolicyID, conversation.InboxID).
|
|
First(&limit).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("load inbox capacity limit: %w", err)
|
|
}
|
|
|
|
q := db.WithContext(ctx).Model(&model.Conversation{}).
|
|
Where("account_id = ? AND inbox_id = ? AND assignee_id = ? AND status = ?", accountID, conversation.InboxID, assigneeID, model.ConversationStatusOpen).
|
|
Where("id <> ?", conversation.ID)
|
|
var assignedOpenCount int64
|
|
if err := q.Count(&assignedOpenCount).Error; err != nil {
|
|
return fmt.Errorf("count assigned open conversations: %w", err)
|
|
}
|
|
if assignedOpenCount >= int64(limit.ConversationLimit) {
|
|
return fmt.Errorf("assignee has reached capacity for this inbox")
|
|
}
|
|
return nil
|
|
}
|