mirror of
https://github.com/abhinavxd/libredesk.git
synced 2026-09-23 11:03:43 +00:00
88a58c0f3b
- New meta JSONB field in CSAT responses to capture random adhoc data as that might be needed for custom CSAT pages. - Add support prefilled rating for csat pages picked from query param
1801 lines
69 KiB
Go
1801 lines
69 KiB
Go
// Package conversation manages conversations and messages.
|
|
package conversation
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"embed"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/abhinavxd/libredesk/internal/automation"
|
|
amodels "github.com/abhinavxd/libredesk/internal/automation/models"
|
|
"github.com/abhinavxd/libredesk/internal/conversation/models"
|
|
pmodels "github.com/abhinavxd/libredesk/internal/conversation/priority/models"
|
|
smodels "github.com/abhinavxd/libredesk/internal/conversation/status/models"
|
|
csatModels "github.com/abhinavxd/libredesk/internal/csat/models"
|
|
"github.com/abhinavxd/libredesk/internal/dbutil"
|
|
"github.com/abhinavxd/libredesk/internal/envelope"
|
|
"github.com/abhinavxd/libredesk/internal/inbox"
|
|
imodels "github.com/abhinavxd/libredesk/internal/inbox/models"
|
|
mmodels "github.com/abhinavxd/libredesk/internal/media/models"
|
|
notifier "github.com/abhinavxd/libredesk/internal/notification"
|
|
nmodels "github.com/abhinavxd/libredesk/internal/notification/models"
|
|
slaModels "github.com/abhinavxd/libredesk/internal/sla/models"
|
|
"github.com/abhinavxd/libredesk/internal/stringutil"
|
|
tmodels "github.com/abhinavxd/libredesk/internal/team/models"
|
|
"github.com/abhinavxd/libredesk/internal/template"
|
|
umodels "github.com/abhinavxd/libredesk/internal/user/models"
|
|
wmodels "github.com/abhinavxd/libredesk/internal/webhook/models"
|
|
"github.com/abhinavxd/libredesk/internal/ws"
|
|
"github.com/jmoiron/sqlx"
|
|
"github.com/jmoiron/sqlx/types"
|
|
"github.com/knadh/go-i18n"
|
|
"github.com/lib/pq"
|
|
"github.com/volatiletech/null/v9"
|
|
"github.com/zerodha/logf"
|
|
)
|
|
|
|
var (
|
|
//go:embed queries.sql
|
|
efs embed.FS
|
|
errConversationNotFound = errors.New("conversation not found")
|
|
conversationsAllowedFields = []string{"status_id", "priority_id", "assigned_team_id", "assigned_user_id", "inbox_id", "last_message_at", "last_interaction_at", "created_at", "waiting_since", "next_sla_deadline_at", "priority_id"}
|
|
conversationStatusAllowedFields = []string{"id", "name"}
|
|
usersAllowedFields = []string{"email"}
|
|
)
|
|
|
|
const (
|
|
conversationsListMaxPageSize = 500
|
|
)
|
|
|
|
// Manager handles the operations related to conversations
|
|
type Manager struct {
|
|
q queries
|
|
inboxStore inboxStore
|
|
userStore userStore
|
|
teamStore teamStore
|
|
mediaStore mediaStore
|
|
statusStore statusStore
|
|
priorityStore priorityStore
|
|
slaStore slaStore
|
|
settingsStore settingsStore
|
|
csatStore csatStore
|
|
webhookStore webhookStore
|
|
dispatcher *notifier.Dispatcher
|
|
lo *logf.Logger
|
|
db *sqlx.DB
|
|
i18n *i18n.I18n
|
|
automation *automation.Engine
|
|
wsHub *ws.Hub
|
|
template *template.Manager
|
|
incomingMessageQueue chan models.IncomingMessage
|
|
outgoingMessageQueue chan models.Message
|
|
outgoingProcessingMessages sync.Map
|
|
closed bool
|
|
closedMu sync.RWMutex
|
|
wg sync.WaitGroup
|
|
continuityConfig ContinuityConfig
|
|
}
|
|
|
|
// WidgetConversationView represents the conversation data for widget clients
|
|
type WidgetConversationView struct {
|
|
UUID string `json:"uuid"`
|
|
Status string `json:"status"`
|
|
Assignee interface{} `json:"assignee"`
|
|
BusinessHoursID *int `json:"business_hours_id,omitempty"`
|
|
WorkingHoursUTCOffset *int `json:"working_hours_utc_offset,omitempty"`
|
|
}
|
|
|
|
// WidgetConversationResponse represents the full conversation response for widget with messages
|
|
type WidgetConversationResponse struct {
|
|
Conversation models.ChatConversation `json:"conversation"`
|
|
Messages []models.ChatMessage `json:"messages"`
|
|
BusinessHoursID *int `json:"business_hours_id,omitempty"`
|
|
WorkingHoursUTCOffset *int `json:"working_hours_utc_offset,omitempty"`
|
|
}
|
|
|
|
type slaStore interface {
|
|
ApplySLA(startTime time.Time, conversationID, assignedTeamID, slaID int) (slaModels.SLAPolicy, error)
|
|
CreateNextResponseSLAEvent(conversationID, appliedSLAID, slaPolicyID, assignedTeamID int) (time.Time, error)
|
|
SetLatestSLAEventMetAt(appliedSLAID int, metric string) (time.Time, error)
|
|
}
|
|
|
|
type statusStore interface {
|
|
Get(int) (smodels.Status, error)
|
|
}
|
|
|
|
type priorityStore interface {
|
|
Get(int) (pmodels.Priority, error)
|
|
}
|
|
|
|
type teamStore interface {
|
|
Get(int) (tmodels.Team, error)
|
|
UserBelongsToTeam(userID, teamID int) (bool, error)
|
|
GetMembers(int) ([]tmodels.TeamMember, error)
|
|
}
|
|
|
|
type userStore interface {
|
|
Get(int, string, []string) (umodels.User, error)
|
|
GetAgent(int, string) (umodels.User, error)
|
|
GetSystemUser() (umodels.User, error)
|
|
CreateContact(user *umodels.User) error
|
|
UpgradeVisitorToContact(visitorID int) error
|
|
}
|
|
|
|
type mediaStore interface {
|
|
GetBlob(name string) ([]byte, error)
|
|
GetURL(uuid, contentType, fileName string) string
|
|
GetSignedURL(name string) string
|
|
Attach(id int, model string, modelID int) error
|
|
GetByModel(id int, model string) ([]mmodels.Media, error)
|
|
ContentIDExists(contentID string) (bool, string, error)
|
|
Upload(fileName, contentType string, content io.ReadSeeker) (string, string, error)
|
|
UploadAndInsert(fileName, contentType, contentID string, modelType null.String, modelID null.Int, content io.ReadSeeker, fileSize int, disposition null.String, meta []byte) (mmodels.Media, error)
|
|
}
|
|
|
|
type inboxStore interface {
|
|
Get(int) (inbox.Inbox, error)
|
|
GetDBRecord(any) (imodels.Inbox, error)
|
|
GetAll() ([]imodels.Inbox, error)
|
|
}
|
|
|
|
type settingsStore interface {
|
|
GetAppRootURL() (string, error)
|
|
GetByPrefix(prefix string) (types.JSONText, error)
|
|
Get(key string) (types.JSONText, error)
|
|
}
|
|
|
|
type csatStore interface {
|
|
Create(conversationID int) (csatModels.CSATResponse, error)
|
|
Get(uuid string) (csatModels.CSATResponse, error)
|
|
MakePublicURL(appBaseURL, uuid string) string
|
|
}
|
|
|
|
type webhookStore interface {
|
|
TriggerEvent(event wmodels.WebhookEvent, data any)
|
|
}
|
|
|
|
// ContinuityConfig holds configuration for conversation continuity emails
|
|
type ContinuityConfig struct {
|
|
BatchCheckInterval time.Duration
|
|
}
|
|
|
|
// Opts holds the options for creating a new Manager.
|
|
type Opts struct {
|
|
DB *sqlx.DB
|
|
Lo *logf.Logger
|
|
OutgoingMessageQueueSize int
|
|
IncomingMessageQueueSize int
|
|
ContinuityConfig *ContinuityConfig
|
|
}
|
|
|
|
// New initializes a new conversation Manager.
|
|
func New(
|
|
wsHub *ws.Hub,
|
|
i18n *i18n.I18n,
|
|
slaStore slaStore,
|
|
statusStore statusStore,
|
|
priorityStore priorityStore,
|
|
inboxStore inboxStore,
|
|
userStore userStore,
|
|
teamStore teamStore,
|
|
mediaStore mediaStore,
|
|
settingsStore settingsStore,
|
|
csatStore csatStore,
|
|
automation *automation.Engine,
|
|
template *template.Manager,
|
|
webhook webhookStore,
|
|
dispatcher *notifier.Dispatcher,
|
|
opts Opts) (*Manager, error) {
|
|
|
|
var q queries
|
|
if err := dbutil.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Default continuity config
|
|
continuityConfig := ContinuityConfig{
|
|
BatchCheckInterval: 5 * time.Minute,
|
|
}
|
|
|
|
// Override with provided config if available
|
|
if opts.ContinuityConfig != nil {
|
|
if opts.ContinuityConfig.BatchCheckInterval > 0 {
|
|
continuityConfig.BatchCheckInterval = opts.ContinuityConfig.BatchCheckInterval
|
|
}
|
|
}
|
|
|
|
c := &Manager{
|
|
q: q,
|
|
wsHub: wsHub,
|
|
i18n: i18n,
|
|
dispatcher: dispatcher,
|
|
inboxStore: inboxStore,
|
|
userStore: userStore,
|
|
teamStore: teamStore,
|
|
mediaStore: mediaStore,
|
|
settingsStore: settingsStore,
|
|
csatStore: csatStore,
|
|
webhookStore: webhook,
|
|
slaStore: slaStore,
|
|
statusStore: statusStore,
|
|
priorityStore: priorityStore,
|
|
automation: automation,
|
|
template: template,
|
|
db: opts.DB,
|
|
lo: opts.Lo,
|
|
incomingMessageQueue: make(chan models.IncomingMessage, opts.IncomingMessageQueueSize),
|
|
outgoingMessageQueue: make(chan models.Message, opts.OutgoingMessageQueueSize),
|
|
outgoingProcessingMessages: sync.Map{},
|
|
continuityConfig: continuityConfig,
|
|
}
|
|
|
|
return c, nil
|
|
}
|
|
|
|
type queries struct {
|
|
// Conversation queries.
|
|
GetConversationUUID *sqlx.Stmt `query:"get-conversation-uuid"`
|
|
GetConversation *sqlx.Stmt `query:"get-conversation"`
|
|
GetConversationsCreatedAfter *sqlx.Stmt `query:"get-conversations-created-after"`
|
|
GetUnassignedConversations *sqlx.Stmt `query:"get-unassigned-conversations"`
|
|
GetConversations string `query:"get-conversations"`
|
|
GetContactChatConversations *sqlx.Stmt `query:"get-contact-chat-conversations"`
|
|
GetChatConversation *sqlx.Stmt `query:"get-chat-conversation"`
|
|
GetContactPreviousConversations *sqlx.Stmt `query:"get-contact-previous-conversations"`
|
|
GetConversationParticipants *sqlx.Stmt `query:"get-conversation-participants"`
|
|
GetUserActiveConversationsCount *sqlx.Stmt `query:"get-user-active-conversations-count"`
|
|
UpdateConversationWaitingSince *sqlx.Stmt `query:"update-conversation-waiting-since"`
|
|
UpdateConversationReplyTimestamps *sqlx.Stmt `query:"update-conversation-reply-timestamps"`
|
|
UpdateConversationContactLastSeen *sqlx.Stmt `query:"update-conversation-contact-last-seen"`
|
|
UpsertUserLastSeen *sqlx.Stmt `query:"upsert-user-last-seen"`
|
|
MarkConversationUnread *sqlx.Stmt `query:"mark-conversation-unread"`
|
|
UpdateConversationAssignedUser *sqlx.Stmt `query:"update-conversation-assigned-user"`
|
|
UpdateConversationAssignedTeam *sqlx.Stmt `query:"update-conversation-assigned-team"`
|
|
UpdateConversationCustomAttributes *sqlx.Stmt `query:"update-conversation-custom-attributes"`
|
|
UpdateConversationPriority *sqlx.Stmt `query:"update-conversation-priority"`
|
|
UpdateConversationStatus *sqlx.Stmt `query:"update-conversation-status"`
|
|
UpdateConversationLastMessage *sqlx.Stmt `query:"update-conversation-last-message"`
|
|
InsertConversationParticipant *sqlx.Stmt `query:"insert-conversation-participant"`
|
|
InsertConversation *sqlx.Stmt `query:"insert-conversation"`
|
|
AddConversationTags *sqlx.Stmt `query:"add-conversation-tags"`
|
|
SetConversationTags *sqlx.Stmt `query:"set-conversation-tags"`
|
|
RemoveConversationTags *sqlx.Stmt `query:"remove-conversation-tags"`
|
|
GetConversationTags *sqlx.Stmt `query:"get-conversation-tags"`
|
|
UnassignOpenConversations *sqlx.Stmt `query:"unassign-open-conversations"`
|
|
ReOpenConversation *sqlx.Stmt `query:"re-open-conversation"`
|
|
UnsnoozeAll *sqlx.Stmt `query:"unsnooze-all"`
|
|
DeleteConversation *sqlx.Stmt `query:"delete-conversation"`
|
|
RemoveConversationAssignee *sqlx.Stmt `query:"remove-conversation-assignee"`
|
|
GetLatestMessage *sqlx.Stmt `query:"get-latest-message"`
|
|
|
|
// Draft queries.
|
|
UpsertConversationDraft *sqlx.Stmt `query:"upsert-conversation-draft"`
|
|
GetAllUserDrafts *sqlx.Stmt `query:"get-all-user-drafts"`
|
|
DeleteConversationDraft *sqlx.Stmt `query:"delete-conversation-draft"`
|
|
DeleteStaleDrafts *sqlx.Stmt `query:"delete-stale-drafts"`
|
|
|
|
// Message queries.
|
|
GetMessage *sqlx.Stmt `query:"get-message"`
|
|
GetMessages string `query:"get-messages"`
|
|
GetOutgoingPendingMessages *sqlx.Stmt `query:"get-outgoing-pending-messages"`
|
|
GetMessageSourceIDs *sqlx.Stmt `query:"get-message-source-ids"`
|
|
GetConversationUUIDFromMessageUUID *sqlx.Stmt `query:"get-conversation-uuid-from-message-uuid"`
|
|
MessageExistsBySourceID *sqlx.Stmt `query:"message-exists-by-source-id"`
|
|
GetConversationByMessageID *sqlx.Stmt `query:"get-conversation-by-message-id"`
|
|
InsertMessage *sqlx.Stmt `query:"insert-message"`
|
|
UpdateMessageStatus *sqlx.Stmt `query:"update-message-status"`
|
|
UpdateMessageSourceID *sqlx.Stmt `query:"update-message-source-id"`
|
|
DeleteMessage *sqlx.Stmt `query:"delete-message"`
|
|
|
|
// Conversation continuity queries.
|
|
GetOfflineLiveChatConversations *sqlx.Stmt `query:"get-offline-livechat-conversations"`
|
|
GetUnreadMessages *sqlx.Stmt `query:"get-unread-messages"`
|
|
MarkMessagesContinuityEmailed *sqlx.Stmt `query:"mark-messages-continuity-emailed"`
|
|
UpdateContinuityEmailTracking *sqlx.Stmt `query:"update-continuity-email-tracking"`
|
|
|
|
// Mention queries.
|
|
InsertMention *sqlx.Stmt `query:"insert-mention"`
|
|
|
|
// Broadcast queries.
|
|
GetActiveLivechatConversationsByAgent *sqlx.Stmt `query:"get-active-livechat-conversations-by-agent"`
|
|
}
|
|
|
|
// CreateConversation creates a new conversation. If maxConversations > 0, the insert is
|
|
// atomically rejected when the contact already has >= maxConversations in the given window.
|
|
func (c *Manager) CreateConversation(contactID, inboxID int, lastMessage string, lastMessageAt time.Time, subject string, appendRefNumToSubject bool, meta, customAttributes map[string]any, maxConversations int, rateLimitWindow time.Duration) (int, string, error) {
|
|
var (
|
|
id int
|
|
uuid string
|
|
prefix string
|
|
)
|
|
|
|
if meta == nil {
|
|
meta = map[string]any{}
|
|
}
|
|
if customAttributes == nil {
|
|
customAttributes = map[string]any{}
|
|
}
|
|
|
|
metaJSON, err := json.Marshal(meta)
|
|
if err != nil {
|
|
c.lo.Error("error marshalling conversation meta", "error", err)
|
|
return 0, "", err
|
|
}
|
|
customAttrsJSON, err := json.Marshal(customAttributes)
|
|
if err != nil {
|
|
c.lo.Error("error marshalling conversation custom attributes", "error", err)
|
|
return 0, "", err
|
|
}
|
|
|
|
var since time.Time
|
|
if maxConversations > 0 {
|
|
since = time.Now().Add(-rateLimitWindow)
|
|
}
|
|
|
|
if err := c.q.InsertConversation.QueryRow(contactID, models.StatusOpen, inboxID, lastMessage, lastMessageAt, subject, prefix, appendRefNumToSubject, metaJSON, customAttrsJSON, since, maxConversations).Scan(&id, &uuid); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return 0, "", envelope.NewError(envelope.RateLimitError, c.i18n.T("globals.messages.tooManyRequests"), nil)
|
|
}
|
|
c.lo.Error("error inserting new conversation into the DB", "error", err)
|
|
return 0, "", err
|
|
}
|
|
return id, uuid, nil
|
|
}
|
|
|
|
// GetConversation retrieves a conversation by its ID or UUID.
|
|
func (c *Manager) GetConversation(id int, uuid, refNum string) (models.Conversation, error) {
|
|
var conversation models.Conversation
|
|
var uuidParam any
|
|
if uuid != "" {
|
|
uuidParam = uuid
|
|
}
|
|
|
|
if err := c.q.GetConversation.Get(&conversation, id, uuidParam, refNum); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return conversation, envelope.NewError(envelope.NotFoundError,
|
|
c.i18n.T("validation.notFoundConversation"), nil)
|
|
}
|
|
c.lo.Error("error fetching conversation", "error", err)
|
|
return conversation, envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
|
|
// Strip name and extract plain email from "Name <email>"
|
|
if conversation.InboxMail != "" {
|
|
var err error
|
|
conversation.InboxMail, err = stringutil.ExtractEmail(conversation.InboxMail)
|
|
if err != nil {
|
|
c.lo.Error("error extracting email from inbox mail", "inbox_mail", conversation.InboxMail, "error", err)
|
|
}
|
|
}
|
|
|
|
return conversation, nil
|
|
}
|
|
|
|
// GetContactPreviousConversations retrieves previous conversations for a contact with a configurable limit.
|
|
func (c *Manager) GetContactPreviousConversations(contactID int, limit int) ([]models.PreviousConversation, error) {
|
|
var conversations = make([]models.PreviousConversation, 0)
|
|
if err := c.q.GetContactPreviousConversations.Select(&conversations, contactID, limit); err != nil {
|
|
c.lo.Error("error fetching previous conversations", "error", err)
|
|
return conversations, envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
return conversations, nil
|
|
}
|
|
|
|
// GetContactChatConversations retrieves chat conversations for a contact in a specific inbox.
|
|
func (c *Manager) GetContactChatConversations(contactID, inboxID int) ([]models.ChatConversation, error) {
|
|
var conversations = make([]models.ChatConversation, 0)
|
|
if err := c.q.GetContactChatConversations.Select(&conversations, contactID, inboxID); err != nil {
|
|
c.lo.Error("error fetching conversations", "error", err)
|
|
return conversations, envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
for i := range conversations {
|
|
if conversations[i].Assignee.ID == 0 {
|
|
conversations[i].Assignee = nil
|
|
}
|
|
if conversations[i].Assignee != nil {
|
|
c.SignAvatarURL(&conversations[i].Assignee.AvatarURL)
|
|
}
|
|
c.SignAvatarURL(&conversations[i].LastChatMessage.Author.AvatarURL)
|
|
}
|
|
return conversations, nil
|
|
}
|
|
|
|
// GetChatConversation retrieves a single chat conversation by UUID
|
|
func (c *Manager) GetChatConversation(conversationUUID string) (models.ChatConversation, error) {
|
|
var conversation models.ChatConversation
|
|
if err := c.q.GetChatConversation.Get(&conversation, conversationUUID); err != nil {
|
|
c.lo.Error("error fetching chat conversation", "uuid", conversationUUID, "error", err)
|
|
return conversation, envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
if conversation.Assignee.ID == 0 {
|
|
conversation.Assignee = nil
|
|
}
|
|
if conversation.Assignee != nil {
|
|
c.SignAvatarURL(&conversation.Assignee.AvatarURL)
|
|
}
|
|
c.SignAvatarURL(&conversation.LastChatMessage.Author.AvatarURL)
|
|
return conversation, nil
|
|
}
|
|
|
|
// SignAvatarURL converts a raw /uploads/ avatar path to a signed URL.
|
|
func (c *Manager) SignAvatarURL(avatarURL *null.String) {
|
|
if avatarURL == nil || !avatarURL.Valid || avatarURL.String == "" {
|
|
return
|
|
}
|
|
if strings.HasPrefix(avatarURL.String, "/uploads/") {
|
|
*avatarURL = null.StringFrom(c.mediaStore.GetSignedURL(strings.TrimPrefix(avatarURL.String, "/uploads/")))
|
|
}
|
|
}
|
|
|
|
// GetConversationsCreatedAfter retrieves conversations created after the specified time.
|
|
func (c *Manager) GetConversationsCreatedAfter(time time.Time) ([]models.Conversation, error) {
|
|
var conversations = make([]models.Conversation, 0)
|
|
if err := c.q.GetConversationsCreatedAfter.Select(&conversations, time); err != nil {
|
|
c.lo.Error("error fetching conversation", "error", err)
|
|
return conversations, err
|
|
}
|
|
return conversations, nil
|
|
}
|
|
|
|
// UpdateUserLastSeen updates the last seen timestamp for a specific user on a conversation.
|
|
func (c *Manager) UpdateUserLastSeen(uuid string, userID int) error {
|
|
if _, err := c.q.UpsertUserLastSeen.Exec(userID, uuid); err != nil {
|
|
c.lo.Error("error upserting user last seen", "user_id", userID, "conversation_uuid", uuid, "error", err)
|
|
return envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MarkAsUnread marks a conversation as unread for a specific user by setting last_seen to before the last message.
|
|
func (c *Manager) MarkAsUnread(uuid string, userID int) error {
|
|
if _, err := c.q.MarkConversationUnread.Exec(userID, uuid); err != nil {
|
|
c.lo.Error("error marking conversation as unread", "user_id", userID, "conversation_uuid", uuid, "error", err)
|
|
return envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UpdateContactLastSeen updates the last seen timestamp of the contact in the conversation.
|
|
func (c *Manager) UpdateConversationContactLastSeen(uuid string) error {
|
|
if _, err := c.q.UpdateConversationContactLastSeen.Exec(uuid); err != nil {
|
|
c.lo.Error("error updating contact last seen timestamp", "conversation_id", uuid, "error", err)
|
|
return envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
|
|
// Broadcast the property update to all subscribers.
|
|
c.BroadcastConversationUpdate(uuid, map[string]any{"contact_last_seen_at": time.Now().Format(time.RFC3339)})
|
|
return nil
|
|
}
|
|
|
|
// GetConversationParticipants retrieves the participants of a conversation.
|
|
func (c *Manager) GetConversationParticipants(uuid string) ([]models.ConversationParticipant, error) {
|
|
conv := make([]models.ConversationParticipant, 0)
|
|
if err := c.q.GetConversationParticipants.Select(&conv, uuid); err != nil {
|
|
c.lo.Error("error fetching conversation", "error", err)
|
|
return conv, envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
return conv, nil
|
|
}
|
|
|
|
// GetUnassignedConversations retrieves unassigned conversations.
|
|
func (c *Manager) GetUnassignedConversations() ([]models.Conversation, error) {
|
|
var conv []models.Conversation
|
|
if err := c.q.GetUnassignedConversations.Select(&conv); err != nil {
|
|
if err != sql.ErrNoRows {
|
|
c.lo.Error("error fetching conversations", "error", err)
|
|
return conv, err
|
|
}
|
|
}
|
|
return conv, nil
|
|
}
|
|
|
|
// GetConversationUUID retrieves the UUID of a conversation by its ID.
|
|
func (c *Manager) GetConversationUUID(id int) (string, error) {
|
|
var uuid string
|
|
if err := c.q.GetConversationUUID.QueryRow(id).Scan(&uuid); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return uuid, err
|
|
}
|
|
c.lo.Error("fetching conversation from DB", "error", err)
|
|
return uuid, err
|
|
}
|
|
return uuid, nil
|
|
}
|
|
|
|
// GetAllConversationsList retrieves all conversations with optional filtering, ordering, and pagination.
|
|
func (c *Manager) GetAllConversationsList(viewingUserID int, order, orderBy, filters string, page, pageSize int) ([]models.ConversationListItem, error) {
|
|
return c.GetConversations(viewingUserID, 0, []int{}, []string{models.AllConversations}, order, orderBy, filters, page, pageSize)
|
|
}
|
|
|
|
// GetAssignedConversationsList retrieves conversations assigned to a specific user with optional filtering, ordering, and pagination.
|
|
func (c *Manager) GetAssignedConversationsList(viewingUserID, userID int, order, orderBy, filters string, page, pageSize int) ([]models.ConversationListItem, error) {
|
|
return c.GetConversations(viewingUserID, userID, []int{}, []string{models.AssignedConversations}, order, orderBy, filters, page, pageSize)
|
|
}
|
|
|
|
// GetUnassignedConversationsList retrieves conversations assigned to a team the user is part of with optional filtering, ordering, and pagination.
|
|
func (c *Manager) GetUnassignedConversationsList(viewingUserID int, order, orderBy, filters string, page, pageSize int) ([]models.ConversationListItem, error) {
|
|
return c.GetConversations(viewingUserID, 0, []int{}, []string{models.UnassignedConversations}, order, orderBy, filters, page, pageSize)
|
|
}
|
|
|
|
// GetTeamUnassignedConversationsList retrieves conversations assigned to a team with optional filtering, ordering, and pagination.
|
|
func (c *Manager) GetTeamUnassignedConversationsList(viewingUserID, teamID int, order, orderBy, filters string, page, pageSize int) ([]models.ConversationListItem, error) {
|
|
return c.GetConversations(viewingUserID, 0, []int{teamID}, []string{models.TeamUnassignedConversations}, order, orderBy, filters, page, pageSize)
|
|
}
|
|
|
|
// GetMentionedConversationsList retrieves conversations where the user is mentioned (directly or via team).
|
|
func (c *Manager) GetMentionedConversationsList(viewingUserID int, order, orderBy, filters string, page, pageSize int) ([]models.ConversationListItem, error) {
|
|
return c.GetConversations(viewingUserID, 0, []int{}, []string{models.MentionedConversations}, order, orderBy, filters, page, pageSize)
|
|
}
|
|
|
|
// InsertMentions inserts mentions for a message.
|
|
func (c *Manager) InsertMentions(conversationID, messageID, mentionedByUserID int, mentions []models.MentionInput) error {
|
|
for _, mention := range mentions {
|
|
var userID, teamID any
|
|
switch mention.Type {
|
|
case models.MentionTypeAgent:
|
|
userID = mention.ID
|
|
case models.MentionTypeTeam:
|
|
teamID = mention.ID
|
|
default:
|
|
c.lo.Warn("invalid mention type, skipping", "type", mention.Type)
|
|
continue
|
|
}
|
|
|
|
if _, err := c.q.InsertMention.Exec(conversationID, messageID, userID, teamID, mentionedByUserID); err != nil {
|
|
c.lo.Error("error inserting mention", "error", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Manager) GetViewConversationsList(viewingUserID, userID int, teamIDs []int, listType []string, order, orderBy, filters string, page, pageSize int) ([]models.ConversationListItem, error) {
|
|
return c.GetConversations(viewingUserID, userID, teamIDs, listType, order, orderBy, filters, page, pageSize)
|
|
}
|
|
|
|
// GetConversations retrieves conversations list based on user ID, type, and optional filtering, ordering, and pagination.
|
|
// viewingUserID is used to calculate per-agent unread counts.
|
|
func (c *Manager) GetConversations(viewingUserID, userID int, teamIDs []int, listTypes []string, order, orderBy, filters string, page, pageSize int) ([]models.ConversationListItem, error) {
|
|
var conversations = make([]models.ConversationListItem, 0)
|
|
|
|
// Make the query.
|
|
query, qArgs, err := c.makeConversationsListQuery(viewingUserID, userID, teamIDs, listTypes, c.q.GetConversations, order, orderBy, page, pageSize, filters)
|
|
if err != nil {
|
|
c.lo.Error("error making conversations query", "error", err)
|
|
return conversations, envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
|
|
tx, err := c.db.BeginTxx(context.Background(), &sql.TxOptions{
|
|
ReadOnly: true,
|
|
})
|
|
defer tx.Rollback()
|
|
if err != nil {
|
|
c.lo.Error("error preparing get conversations query", "error", err)
|
|
return conversations, envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
|
|
if err := tx.Select(&conversations, query, qArgs...); err != nil {
|
|
c.lo.Error("error fetching conversations", "error", err)
|
|
return conversations, envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
return conversations, nil
|
|
}
|
|
|
|
// ReOpenConversation reopens a conversation if it's snoozed, resolved or closed.
|
|
func (c *Manager) ReOpenConversation(conversationUUID string, actor umodels.User) error {
|
|
rows, err := c.q.ReOpenConversation.Exec(conversationUUID)
|
|
if err != nil {
|
|
c.lo.Error("error reopening conversation", "uuid", conversationUUID, "error", err)
|
|
return envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
|
|
// Record the status change as an activity if the conversation was reopened.
|
|
count, _ := rows.RowsAffected()
|
|
if count > 0 {
|
|
// Broadcast update using WS
|
|
c.BroadcastConversationUpdate(conversationUUID, map[string]any{"status": models.StatusOpen})
|
|
|
|
// Record the status change as an activity.
|
|
if err := c.RecordStatusChange(models.StatusOpen, conversationUUID, actor); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ActiveUserConversationsCount returns the count of active conversations for a user. i.e. conversations not closed or resolved status.
|
|
func (c *Manager) ActiveUserConversationsCount(userID int) (int, error) {
|
|
var count int
|
|
if err := c.q.GetUserActiveConversationsCount.Get(&count, userID); err != nil {
|
|
c.lo.Error("error fetching active conversation count", "error", err)
|
|
return count, envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
return count, nil
|
|
}
|
|
|
|
// UpdateConversationLastMessage updates the last message details for a conversation.
|
|
// Also conditionally updates last_interaction fields if messageType != 'activity' and !private.
|
|
func (c *Manager) UpdateConversationLastMessage(conversation int, conversationUUID, lastMessage, lastMessageSenderType, messageType string, private bool, lastMessageAt time.Time, senderID int) error {
|
|
if _, err := c.q.UpdateConversationLastMessage.Exec(conversation, conversationUUID, lastMessage, lastMessageSenderType, lastMessageAt, messageType, private, senderID); err != nil {
|
|
c.lo.Error("error updating conversation last message", "error", err)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UpdateConversationWaitingSince updates the waiting since timestamp for a conversation.
|
|
func (c *Manager) UpdateConversationWaitingSince(conversationUUID string, at *time.Time) error {
|
|
res, err := c.q.UpdateConversationWaitingSince.Exec(conversationUUID, at)
|
|
if err != nil {
|
|
c.lo.Error("error updating conversation waiting since", "error", err)
|
|
return err
|
|
}
|
|
|
|
rows, _ := res.RowsAffected()
|
|
if rows > 0 {
|
|
if at != nil {
|
|
c.BroadcastConversationUpdate(conversationUUID, map[string]any{"waiting_since": at.Format(time.RFC3339)})
|
|
} else {
|
|
c.BroadcastConversationUpdate(conversationUUID, map[string]any{"waiting_since": nil})
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UpdateConversationUserAssignee sets the assignee of a conversation to a specifc user.
|
|
func (c *Manager) UpdateConversationUserAssignee(uuid string, assigneeID int, actor umodels.User) error {
|
|
if err := c.UpdateAssignee(uuid, assigneeID, models.AssigneeTypeUser); err != nil {
|
|
return envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
|
|
// Refetch the conversation to get the updated details.
|
|
conversation, err := c.GetConversation(0, uuid, "")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
c.webhookStore.TriggerEvent(wmodels.EventConversationAssigned, map[string]any{
|
|
"conversation_uuid": uuid,
|
|
"assigned_to": assigneeID,
|
|
"actor_id": actor.ID,
|
|
"conversation": conversation,
|
|
})
|
|
|
|
// Evaluate automation rules.
|
|
c.automation.EvaluateConversationUpdateRules(conversation, amodels.EventConversationUserAssigned)
|
|
|
|
// Send notifications to assignee (skip if self-assigning).
|
|
if assigneeID != actor.ID {
|
|
if err := c.NotifyAssignment([]int{assigneeID}, conversation); err != nil {
|
|
c.lo.Error("error sending assignment notification", "error", err)
|
|
}
|
|
}
|
|
|
|
if err := c.RecordAssigneeUserChange(uuid, assigneeID, actor); err != nil {
|
|
return envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
|
|
// Broadcast conversation update to widget clients with assignee info.
|
|
agent, err := c.userStore.GetAgent(assigneeID, "")
|
|
if err == nil {
|
|
c.SignAvatarURL(&agent.AvatarURL)
|
|
c.BroadcastConversationToWidget(uuid, conversation.ContactID, conversation.InboxID, map[string]any{
|
|
"assignee": map[string]any{
|
|
"id": agent.ID,
|
|
"first_name": agent.FirstName,
|
|
"last_name": agent.LastName,
|
|
"avatar_url": agent.AvatarURL,
|
|
"availability_status": agent.AvailabilityStatus,
|
|
},
|
|
})
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// UpdateConversationTeamAssignee sets the assignee of a conversation to a specific team and sets the assigned user id to NULL.
|
|
func (c *Manager) UpdateConversationTeamAssignee(uuid string, teamID int, actor umodels.User) error {
|
|
// Store previously assigned team ID to apply SLA policy if team has changed.
|
|
conversation, err := c.GetConversation(0, uuid, "")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
previousAssignedTeamID := conversation.AssignedTeamID.Int
|
|
|
|
if err := c.UpdateAssignee(uuid, teamID, models.AssigneeTypeTeam); err != nil {
|
|
return envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
|
|
// Assignment successful, any errors now are non-critical and can be ignored by returning nil.
|
|
if err := c.RecordAssigneeTeamChange(uuid, teamID, actor); err != nil {
|
|
return nil
|
|
}
|
|
|
|
// Team changed?
|
|
if previousAssignedTeamID != teamID {
|
|
// Remove assigned user if team has changed.
|
|
c.RemoveConversationAssignee(uuid, models.AssigneeTypeUser, actor)
|
|
|
|
// Apply SLA policy if this new team has a SLA policy.
|
|
team, err := c.teamStore.Get(teamID)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
// Fetch the conversation again to get the updated details.
|
|
conversation, err := c.GetConversation(0, uuid, "")
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
if team.SLAPolicyID.Int > 0 {
|
|
systemUser, err := c.userStore.GetSystemUser()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
if err := c.ApplySLA(conversation, team.SLAPolicyID.Int, systemUser); err != nil {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// Evaluate automation rules for conversation team assignment.
|
|
c.automation.EvaluateConversationUpdateRules(conversation, amodels.EventConversationTeamAssigned)
|
|
}
|
|
|
|
// Broadcast conversation update to widget clients.
|
|
c.BroadcastConversationToWidget(uuid, conversation.ContactID, conversation.InboxID, map[string]any{
|
|
"assigned_team_id": teamID,
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
// UpdateAssignee updates the assignee of a conversation.
|
|
func (c *Manager) UpdateAssignee(uuid string, assigneeID int, assigneeType string) error {
|
|
var prop string
|
|
switch assigneeType {
|
|
case models.AssigneeTypeUser:
|
|
prop = "assigned_user_id"
|
|
if _, err := c.q.UpdateConversationAssignedUser.Exec(uuid, assigneeID); err != nil {
|
|
c.lo.Error("error updating conversation assignee", "error", err)
|
|
return fmt.Errorf("updating assignee: %w", err)
|
|
}
|
|
case models.AssigneeTypeTeam:
|
|
prop = "assigned_team_id"
|
|
if _, err := c.q.UpdateConversationAssignedTeam.Exec(uuid, assigneeID); err != nil {
|
|
c.lo.Error("error updating conversation assignee", "error", err)
|
|
return fmt.Errorf("updating assignee: %w", err)
|
|
}
|
|
default:
|
|
return fmt.Errorf("invalid assignee type: %s", assigneeType)
|
|
}
|
|
// Broadcast update to all subscribers.
|
|
c.BroadcastConversationUpdate(uuid, map[string]any{prop: assigneeID})
|
|
return nil
|
|
}
|
|
|
|
// UpdateConversationPriority updates the priority of a conversation.
|
|
func (c *Manager) UpdateConversationPriority(uuid string, priorityID int, priority string, actor umodels.User) error {
|
|
// Fetch the priority name if priority ID is provided.
|
|
if priorityID > 0 {
|
|
p, err := c.priorityStore.Get(priorityID)
|
|
if err != nil {
|
|
return envelope.NewError(envelope.InputError, err.Error(), nil)
|
|
}
|
|
priority = p.Name
|
|
}
|
|
if _, err := c.q.UpdateConversationPriority.Exec(uuid, priority); err != nil {
|
|
c.lo.Error("error updating conversation priority", "error", err)
|
|
return envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
|
|
// Evaluate automation rules for conversation priority change.
|
|
conversation, err := c.GetConversation(0, uuid, "")
|
|
if err == nil {
|
|
c.automation.EvaluateConversationUpdateRules(conversation, amodels.EventConversationPriorityChange)
|
|
}
|
|
|
|
// Record activity.
|
|
if err := c.RecordPriorityChange(priority, uuid, actor); err != nil {
|
|
return envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
c.BroadcastConversationUpdate(uuid, map[string]any{"priority": priority})
|
|
return nil
|
|
}
|
|
|
|
// UpdateConversationStatus updates the status of a conversation.
|
|
func (c *Manager) UpdateConversationStatus(uuid string, statusID int, status, snoozeDur string, actor umodels.User) error {
|
|
// Fetch the status name if status ID is provided.
|
|
if statusID > 0 {
|
|
s, err := c.statusStore.Get(statusID)
|
|
if err != nil {
|
|
return envelope.NewError(envelope.InputError, err.Error(), nil)
|
|
}
|
|
status = s.Name
|
|
}
|
|
|
|
if status == models.StatusSnoozed && snoozeDur == "" {
|
|
return envelope.NewError(envelope.InputError, c.i18n.T("validation.invalidSnoozeDuration"), nil)
|
|
}
|
|
|
|
// Parse the snooze duration if status is snoozed.
|
|
snoozeUntil := time.Time{}
|
|
if status == models.StatusSnoozed {
|
|
duration, err := time.ParseDuration(snoozeDur)
|
|
if err != nil {
|
|
c.lo.Error("error parsing snooze duration", "error", err)
|
|
return envelope.NewError(envelope.InputError, c.i18n.T("validation.invalidSnoozeDuration"), nil)
|
|
}
|
|
snoozeUntil = time.Now().Add(duration)
|
|
}
|
|
|
|
conversationBeforeChange, err := c.GetConversation(0, uuid, "")
|
|
if err != nil {
|
|
c.lo.Error("error fetching conversation before status change", "uuid", uuid, "error", err)
|
|
return envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
oldStatus := conversationBeforeChange.Status.String
|
|
|
|
// Status not changed and not snoozed. Return early.
|
|
if oldStatus == status && status != models.StatusSnoozed {
|
|
c.lo.Debug("no status update: conversation status unchanged and not snoozed", "uuid", uuid, "old_status", oldStatus, "new_status", status)
|
|
return nil
|
|
}
|
|
|
|
// Update the conversation status.
|
|
if _, err := c.q.UpdateConversationStatus.Exec(uuid, status, snoozeUntil); err != nil {
|
|
c.lo.Error("error updating conversation status", "error", err)
|
|
return envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
|
|
// Fetch conversation for webhook and automation rules.
|
|
conversation, err := c.GetConversation(0, uuid, "")
|
|
if err != nil {
|
|
c.lo.Error("error fetching conversation after status change", "uuid", uuid, "error", err)
|
|
}
|
|
|
|
// Trigger webhook for conversation status change
|
|
var snoozeUntilStr string
|
|
if !snoozeUntil.IsZero() {
|
|
snoozeUntilStr = snoozeUntil.UTC().Format(time.RFC3339)
|
|
}
|
|
c.webhookStore.TriggerEvent(wmodels.EventConversationStatusChanged, map[string]any{
|
|
"conversation_uuid": uuid,
|
|
"previous_status": oldStatus,
|
|
"new_status": status,
|
|
"snooze_until": snoozeUntilStr,
|
|
"actor_id": actor.ID,
|
|
"conversation": conversation,
|
|
})
|
|
|
|
// Record the status change as an activity.
|
|
if err := c.RecordStatusChange(status, uuid, actor); err != nil {
|
|
return envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
|
|
// Broadcast updates using websocket.
|
|
agentData := map[string]any{"status": status}
|
|
if oldStatus != models.StatusResolved && status == models.StatusResolved {
|
|
resolvedAt := conversationBeforeChange.ResolvedAt.Time
|
|
if resolvedAt.IsZero() {
|
|
resolvedAt = time.Now()
|
|
}
|
|
agentData["resolved_at"] = resolvedAt.Format(time.RFC3339)
|
|
}
|
|
c.BroadcastConversationUpdate(uuid, agentData)
|
|
|
|
// Evaluate automation rules.
|
|
if conversation.ID != 0 {
|
|
c.automation.EvaluateConversationUpdateRules(conversation, amodels.EventConversationStatusChange)
|
|
}
|
|
|
|
// Broadcast conversation update to widget clients.
|
|
c.BroadcastConversationToWidget(uuid, conversationBeforeChange.ContactID, conversationBeforeChange.InboxID, map[string]any{
|
|
"status": status,
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
// SetConversationTags sets the tags associated with a conversation.
|
|
func (c *Manager) SetConversationTags(uuid string, action string, tagNames []string, actor umodels.User) error {
|
|
// Get current tags list.
|
|
prevTags, err := c.getConversationTags(uuid)
|
|
if err != nil {
|
|
return envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
if prevTags == nil {
|
|
prevTags = []string{}
|
|
}
|
|
|
|
// Add specified tags, ignore existing ones.
|
|
if action == amodels.ActionAddTags {
|
|
if _, err := c.q.AddConversationTags.Exec(uuid, pq.Array(tagNames)); err != nil {
|
|
c.lo.Error("error adding conversation tags", "error", err)
|
|
return envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
}
|
|
|
|
// Set specified tags and remove all other existing ones.
|
|
if action == amodels.ActionSetTags {
|
|
if _, err := c.q.SetConversationTags.Exec(uuid, pq.Array(tagNames)); err != nil {
|
|
c.lo.Error("error setting conversation tags", "error", err)
|
|
return envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
}
|
|
|
|
// Delete specified tags, ignore all others.
|
|
if action == amodels.ActionRemoveTags {
|
|
if _, err := c.q.RemoveConversationTags.Exec(uuid, pq.Array(tagNames)); err != nil {
|
|
c.lo.Error("error removing conversation tags", "error", err)
|
|
return envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
}
|
|
|
|
// Get updated tags list.
|
|
newTags, err := c.getConversationTags(uuid)
|
|
if err != nil {
|
|
return envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
|
|
// Fetch conversation for webhook.
|
|
conversation, err := c.GetConversation(0, uuid, "")
|
|
if err != nil {
|
|
c.lo.Error("error fetching conversation after tags change", "uuid", uuid, "error", err)
|
|
}
|
|
|
|
// Trigger webhook for conversation tags changed.
|
|
if newTags == nil {
|
|
newTags = []string{}
|
|
}
|
|
c.webhookStore.TriggerEvent(wmodels.EventConversationTagsChanged, map[string]any{
|
|
"conversation_uuid": uuid,
|
|
"previous_tags": prevTags,
|
|
"new_tags": newTags,
|
|
"actor_id": actor.ID,
|
|
"conversation": conversation,
|
|
})
|
|
|
|
// Find actually removed tags.
|
|
for _, tag := range prevTags {
|
|
if slices.Contains(newTags, tag) {
|
|
continue
|
|
}
|
|
// Record the removed tags as activities.
|
|
if err := c.RecordTagRemoval(uuid, tag, actor); err != nil {
|
|
return envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
}
|
|
|
|
// Find actually added tags.
|
|
for _, tag := range newTags {
|
|
if slices.Contains(prevTags, tag) {
|
|
continue
|
|
}
|
|
// Record the added tags as activities.
|
|
if err := c.RecordTagAddition(uuid, tag, actor); err != nil {
|
|
return envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetMessageSourceIDs retrieves source IDs for messages in a conversation in descending order.
|
|
// So the oldest message will be the last in the list.
|
|
func (m *Manager) GetMessageSourceIDs(conversationID, limit int) ([]string, error) {
|
|
var refs []string
|
|
if err := m.q.GetMessageSourceIDs.Select(&refs, conversationID, limit); err != nil {
|
|
m.lo.Error("error fetching message source IDs", "conversation_id", conversationID, "error", err)
|
|
return refs, err
|
|
}
|
|
return refs, nil
|
|
}
|
|
|
|
// NotifyAssignment sends notifications (in-app, WebSocket, email) for an assigned conversation.
|
|
func (m *Manager) NotifyAssignment(userIDs []int, conversation models.Conversation) error {
|
|
agent, err := m.userStore.GetAgent(userIDs[0], "")
|
|
if err != nil {
|
|
m.lo.Error("error fetching agent", "user_id", userIDs[0], "error", err)
|
|
return fmt.Errorf("fetching agent: %w", err)
|
|
}
|
|
|
|
// Render email template.
|
|
content, subject, err := m.template.RenderStoredEmailTemplate(template.TmplConversationAssigned,
|
|
map[string]any{
|
|
"Conversation": map[string]any{
|
|
"ReferenceNumber": conversation.ReferenceNumber,
|
|
"Subject": conversation.Subject.String,
|
|
"Priority": conversation.Priority.String,
|
|
"UUID": conversation.UUID,
|
|
},
|
|
"Contact": map[string]any{
|
|
"FirstName": conversation.Contact.FirstName,
|
|
"LastName": conversation.Contact.LastName,
|
|
"FullName": conversation.Contact.FullName(),
|
|
"Email": conversation.Contact.Email.String,
|
|
},
|
|
"Recipient": map[string]any{
|
|
"FirstName": agent.FirstName,
|
|
"LastName": agent.LastName,
|
|
"FullName": agent.FullName(),
|
|
"Email": agent.Email.String,
|
|
},
|
|
// Automated messages do not have an author.
|
|
"Author": map[string]any{
|
|
"FirstName": "",
|
|
"LastName": "",
|
|
"FullName": "",
|
|
"Email": "",
|
|
},
|
|
})
|
|
if err != nil {
|
|
m.lo.Error("error rendering template", "template", template.TmplConversationAssigned, "conversation_uuid", conversation.UUID, "error", err)
|
|
return fmt.Errorf("rendering template: %w", err)
|
|
}
|
|
|
|
// Send notification.
|
|
m.dispatcher.Send(notifier.Notification{
|
|
Type: nmodels.NotificationTypeAssignment,
|
|
RecipientIDs: []int{agent.ID},
|
|
Title: m.i18n.Ts("notification.conversationAssigned", "referenceNumber", conversation.ReferenceNumber),
|
|
Body: conversation.Subject,
|
|
ConversationID: null.IntFrom(conversation.ID),
|
|
ConversationUUID: conversation.UUID,
|
|
// Parms required for email
|
|
Email: ¬ifier.EmailNotification{
|
|
Recipients: []string{agent.Email.String},
|
|
Subject: subject,
|
|
Content: content,
|
|
},
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// NotifyMention sends notifications (in-app, WebSocket, email) for mentions.
|
|
// For team mentions, expands to all team members.
|
|
func (m *Manager) NotifyMention(conversationUUID string, message models.Message, mentions []models.MentionInput, mentionedByUserID int) {
|
|
conversation, err := m.GetConversation(0, conversationUUID, "")
|
|
if err != nil {
|
|
m.lo.Error("error fetching conversation for mention notification", "uuid", conversationUUID, "error", err)
|
|
return
|
|
}
|
|
|
|
// Get the user who made the mention.
|
|
author, err := m.userStore.GetAgent(mentionedByUserID, "")
|
|
if err != nil {
|
|
m.lo.Error("error fetching author for mention notification", "user_id", mentionedByUserID, "error", err)
|
|
return
|
|
}
|
|
|
|
// Collect all user IDs to notify (avoid duplicates).
|
|
recipientIDMap := make(map[int]struct{})
|
|
|
|
for _, mention := range mentions {
|
|
if mention.Type == models.MentionTypeAgent {
|
|
// Direct user mention.
|
|
recipientIDMap[mention.ID] = struct{}{}
|
|
} else if mention.Type == models.MentionTypeTeam {
|
|
// Team mention - expand to all team members.
|
|
members, err := m.teamStore.GetMembers(mention.ID)
|
|
if err != nil {
|
|
m.lo.Error("error fetching team members for mention notification", "team_id", mention.ID, "error", err)
|
|
continue
|
|
}
|
|
for _, member := range members {
|
|
recipientIDMap[member.ID] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Don't notify the person who made the mention.
|
|
delete(recipientIDMap, mentionedByUserID)
|
|
|
|
// Build recipient list and personalized emails.
|
|
var recipientIDs []int
|
|
var emails []notifier.EmailNotification
|
|
|
|
for userID := range recipientIDMap {
|
|
recipient, err := m.userStore.GetAgent(userID, "")
|
|
if err != nil {
|
|
m.lo.Error("error fetching recipient for mention notification", "user_id", userID, "error", err)
|
|
continue
|
|
}
|
|
|
|
recipientIDs = append(recipientIDs, userID)
|
|
|
|
// Render personalized email for this recipient.
|
|
var email notifier.EmailNotification
|
|
if recipient.Email.String != "" {
|
|
content, subject, err := m.template.RenderStoredEmailTemplate(template.TmplMentioned,
|
|
map[string]any{
|
|
"Conversation": map[string]any{
|
|
"ReferenceNumber": conversation.ReferenceNumber,
|
|
"Subject": conversation.Subject.String,
|
|
"Priority": conversation.Priority.String,
|
|
"UUID": conversation.UUID,
|
|
},
|
|
"Recipient": map[string]any{
|
|
"FirstName": recipient.FirstName,
|
|
"LastName": recipient.LastName,
|
|
"FullName": recipient.FullName(),
|
|
"Email": recipient.Email.String,
|
|
},
|
|
"Message": map[string]any{
|
|
"UUID": message.UUID,
|
|
"Content": message.Content,
|
|
},
|
|
"MentionedBy": map[string]any{
|
|
"FirstName": author.FirstName,
|
|
"LastName": author.LastName,
|
|
"FullName": author.FullName(),
|
|
"Email": author.Email.String,
|
|
},
|
|
// Automated messages do not have an author.
|
|
"Author": map[string]any{
|
|
"FirstName": "",
|
|
"LastName": "",
|
|
"FullName": "",
|
|
"Email": "",
|
|
},
|
|
})
|
|
if err != nil {
|
|
m.lo.Error("error rendering mention notification template", "conversation_uuid", conversationUUID, "error", err)
|
|
} else {
|
|
email = notifier.EmailNotification{
|
|
Recipients: []string{recipient.Email.String},
|
|
Subject: subject,
|
|
Content: content,
|
|
}
|
|
}
|
|
}
|
|
emails = append(emails, email)
|
|
}
|
|
|
|
if len(recipientIDs) == 0 {
|
|
return
|
|
}
|
|
|
|
// Send notification via dispatcher (handles in-app, WebSocket, and email).
|
|
m.dispatcher.SendWithEmails(notifier.Notification{
|
|
Type: nmodels.NotificationTypeMention,
|
|
RecipientIDs: recipientIDs,
|
|
Title: m.i18n.Ts("notification.mentionedInConversation", "author", author.FullName(), "referenceNumber", conversation.ReferenceNumber),
|
|
Body: null.StringFrom(message.TextContent),
|
|
ConversationID: null.IntFrom(conversation.ID),
|
|
MessageID: null.IntFrom(message.ID),
|
|
ActorID: null.IntFrom(mentionedByUserID),
|
|
ConversationUUID: conversation.UUID,
|
|
ActorFirstName: author.FirstName,
|
|
ActorLastName: author.LastName,
|
|
}, emails)
|
|
}
|
|
|
|
// UnassignOpen unassigns all open conversations belonging to a user.
|
|
// i.e conversations without status `Closed` and `Resolved`.
|
|
func (m *Manager) UnassignOpen(userID int) error {
|
|
if _, err := m.q.UnassignOpenConversations.Exec(userID); err != nil {
|
|
m.lo.Error("error unassigning open conversations", "error", err)
|
|
return envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.errorUpdatingConversation"), nil)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ApplySLA applies the SLA policy to a conversation.
|
|
func (m *Manager) ApplySLA(conversation models.Conversation, policyID int, actor umodels.User) error {
|
|
policy, err := m.slaStore.ApplySLA(conversation.CreatedAt, conversation.ID, conversation.AssignedTeamID.Int, policyID)
|
|
if err != nil {
|
|
m.lo.Error("error applying SLA to conversation", "conversation_id", conversation.ID, "policy_id", policyID, "error", err)
|
|
return envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
|
|
// Record the SLA application as an activity.
|
|
if err := m.RecordSLASet(conversation.UUID, policy.Name, actor); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ApplyAction applies an action to a conversation, this can be called from multiple packages across the app to perform actions on conversations.
|
|
// all actions are executed on behalf of the provided user if the user is not provided, system user is used.
|
|
func (m *Manager) ApplyAction(action amodels.RuleAction, conv models.Conversation, user umodels.User) error {
|
|
// CSAT action does not require a value.
|
|
if len(action.Value) == 0 && action.Type != amodels.ActionSendCSAT {
|
|
return fmt.Errorf("empty value for action %s", action.Type)
|
|
}
|
|
|
|
// Fall back to system user if user is not provided.
|
|
if user.ID == 0 {
|
|
var err error
|
|
if user, err = m.userStore.GetSystemUser(); err != nil {
|
|
return fmt.Errorf("get system user: %w", err)
|
|
}
|
|
}
|
|
|
|
m.lo.Debug("executing action",
|
|
"type", action.Type,
|
|
"value", action.Value,
|
|
"conv_uuid", conv.UUID,
|
|
"user_id", user.ID,
|
|
)
|
|
|
|
switch action.Type {
|
|
case amodels.ActionAssignTeam:
|
|
teamID, err := strconv.Atoi(action.Value[0])
|
|
if err != nil {
|
|
return fmt.Errorf("invalid team ID %q: %w", action.Value[0], err)
|
|
}
|
|
return m.UpdateConversationTeamAssignee(conv.UUID, teamID, user)
|
|
case amodels.ActionAssignUser:
|
|
agentID, err := strconv.Atoi(action.Value[0])
|
|
if err != nil {
|
|
return fmt.Errorf("invalid agent ID %q: %w", action.Value[0], err)
|
|
}
|
|
return m.UpdateConversationUserAssignee(conv.UUID, agentID, user)
|
|
case amodels.ActionSetPriority:
|
|
priorityID, err := strconv.Atoi(action.Value[0])
|
|
if err != nil {
|
|
return fmt.Errorf("invalid priority ID %q: %w", action.Value[0], err)
|
|
}
|
|
return m.UpdateConversationPriority(conv.UUID, priorityID, "", user)
|
|
case amodels.ActionSetStatus:
|
|
statusID, err := strconv.Atoi(action.Value[0])
|
|
if err != nil {
|
|
return fmt.Errorf("invalid status ID %q: %w", action.Value[0], err)
|
|
}
|
|
return m.UpdateConversationStatus(conv.UUID, statusID, "", "", user)
|
|
case amodels.ActionSendPrivateNote:
|
|
_, err := m.SendPrivateNote([]mmodels.Media{}, user.ID, conv.UUID, action.Value[0], nil)
|
|
if err != nil {
|
|
return fmt.Errorf("sending private note: %w", err)
|
|
}
|
|
case amodels.ActionReply:
|
|
// Make recipient list.
|
|
to, cc, bcc, err := m.makeRecipients(conv.ID, conv.Contact.Email.String, conv.InboxMail)
|
|
if err != nil {
|
|
return fmt.Errorf("making recipients for reply action: %w", err)
|
|
}
|
|
_, err = m.QueueReply(
|
|
[]mmodels.Media{},
|
|
conv.InboxID,
|
|
user.ID,
|
|
conv.ContactID,
|
|
conv.UUID,
|
|
action.Value[0],
|
|
to,
|
|
cc,
|
|
bcc,
|
|
map[string]any{}, /**meta**/
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("sending reply: %w", err)
|
|
}
|
|
case amodels.ActionSetSLA:
|
|
slaID, err := strconv.Atoi(action.Value[0])
|
|
if err != nil {
|
|
return fmt.Errorf("invalid SLA ID %q: %w", action.Value[0], err)
|
|
}
|
|
return m.ApplySLA(conv, slaID, user)
|
|
case amodels.ActionAddTags, amodels.ActionSetTags, amodels.ActionRemoveTags:
|
|
return m.SetConversationTags(conv.UUID, action.Type, action.Value, user)
|
|
case amodels.ActionSendCSAT:
|
|
return m.SendCSATReply(user.ID, conv)
|
|
default:
|
|
return fmt.Errorf("unknown action: %s", action.Type)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RemoveConversationAssignee removes assigned user from a conversation.
|
|
func (m *Manager) RemoveConversationAssignee(uuid, typ string, actor umodels.User) error {
|
|
if _, err := m.q.RemoveConversationAssignee.Exec(uuid, typ); err != nil {
|
|
m.lo.Error("error removing conversation assignee", "error", err)
|
|
return envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.errorUpdatingConversation"), nil)
|
|
}
|
|
|
|
// Trigger webhook for conversation unassigned from user.
|
|
if typ == models.AssigneeTypeUser {
|
|
conversation, err := m.GetConversation(0, uuid, "")
|
|
if err != nil {
|
|
m.lo.Error("error fetching conversation after unassignment", "uuid", uuid, "error", err)
|
|
}
|
|
m.webhookStore.TriggerEvent(wmodels.EventConversationUnassigned, map[string]any{
|
|
"conversation_uuid": uuid,
|
|
"actor_id": actor.ID,
|
|
"conversation": conversation,
|
|
})
|
|
|
|
// Broadcast conversation update to widget clients when user assignee is removed.
|
|
if conversation.ID != 0 {
|
|
m.BroadcastConversationToWidget(uuid, conversation.ContactID, conversation.InboxID, map[string]any{
|
|
"assignee": nil,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Broadcast ws update.
|
|
switch typ {
|
|
case models.AssigneeTypeUser:
|
|
m.BroadcastConversationUpdate(uuid, map[string]any{"assigned_user_id": nil})
|
|
case models.AssigneeTypeTeam:
|
|
m.BroadcastConversationUpdate(uuid, map[string]any{"assigned_team_id": nil})
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// SendCSATReply sends a CSAT reply message to a conversation.
|
|
func (m *Manager) SendCSATReply(actorUserID int, conversation models.Conversation) error {
|
|
appRootURL, err := m.settingsStore.GetAppRootURL()
|
|
if err != nil {
|
|
return envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
csat, err := m.csatStore.Create(conversation.ID)
|
|
if err != nil {
|
|
return envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
csatPublicURL := m.csatStore.MakePublicURL(appRootURL, csat.UUID)
|
|
|
|
// Render CSAT email template.
|
|
data, err := m.BuildTemplateData(conversation.UUID, actorUserID)
|
|
if err != nil {
|
|
m.lo.Error("error building CSAT template data", "conversation_uuid", conversation.UUID, "error", err)
|
|
return envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
data["CSATLink"] = csatPublicURL
|
|
message, err := m.template.RenderStoredTemplate(template.TmplCSATRequest, data)
|
|
if err != nil {
|
|
m.lo.Error("error rendering CSAT template", "conversation_uuid", conversation.UUID, "error", err)
|
|
return envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
|
|
// Store `is_csat` meta to identify and filter CSAT public url from the message.
|
|
meta := map[string]interface{}{
|
|
"is_csat": true,
|
|
"csat_uuid": csat.UUID,
|
|
}
|
|
|
|
// Make recipient list.
|
|
to, cc, bcc, err := m.makeRecipients(conversation.ID, conversation.Contact.Email.String, conversation.InboxMail)
|
|
if err != nil {
|
|
return envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
|
|
// Send CSAT reply.
|
|
_, err = m.QueueReply(nil /**media**/, conversation.InboxID, actorUserID, conversation.ContactID, conversation.UUID, message, to, cc, bcc, meta)
|
|
if err != nil {
|
|
m.lo.Error("error sending CSAT reply", "conversation_uuid", conversation.UUID, "error", err)
|
|
return envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeleteConversation deletes a conversation.
|
|
func (m *Manager) DeleteConversation(uuid string) error {
|
|
m.lo.Info("deleting conversation", "uuid", uuid)
|
|
if _, err := m.q.DeleteConversation.Exec(uuid); err != nil {
|
|
m.lo.Error("error deleting conversation", "error", err)
|
|
return envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UpdateConversationCustomAttributes updates the custom attributes of a conversation.
|
|
func (c *Manager) UpdateConversationCustomAttributes(uuid string, customAttributes map[string]any) error {
|
|
jsonb, err := json.Marshal(customAttributes)
|
|
if err != nil {
|
|
c.lo.Error("error marshalling custom attributes", "error", err)
|
|
return envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
if _, err := c.q.UpdateConversationCustomAttributes.Exec(uuid, jsonb); err != nil {
|
|
c.lo.Error("error updating conversation custom attributes", "error", err)
|
|
return envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
// Broadcast the custom attributes update.
|
|
c.BroadcastConversationUpdate(uuid, map[string]any{"custom_attributes": customAttributes})
|
|
return nil
|
|
}
|
|
|
|
// addConversationParticipant adds a user as participant to a conversation.
|
|
func (c *Manager) addConversationParticipant(userID int, conversationUUID string) error {
|
|
_, err := c.q.InsertConversationParticipant.Exec(userID, conversationUUID)
|
|
if err != nil {
|
|
if dbutil.IsUniqueViolationError(err) {
|
|
return nil // Already a participant.
|
|
}
|
|
c.lo.Error("error adding conversation participant", "user_id", userID, "conversation_uuid", conversationUUID, "error", err)
|
|
return envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
|
|
// New participant added - log activity only for contacts with a different email than the conversation contact.
|
|
conversation, convErr := c.GetConversation(0, conversationUUID, "")
|
|
if convErr == nil {
|
|
user, userErr := c.userStore.Get(userID, "", []string{})
|
|
if userErr == nil && user.Type == umodels.UserTypeContact && !strings.EqualFold(user.Email.String, conversation.Contact.Email.String) {
|
|
participantName := user.Email.String
|
|
if user.FirstName != "" {
|
|
participantName = user.FirstName
|
|
if user.LastName != "" {
|
|
participantName += " " + user.LastName
|
|
}
|
|
participantName += " (" + user.Email.String + ")"
|
|
}
|
|
systemUser, sysErr := c.userStore.GetSystemUser()
|
|
if sysErr == nil {
|
|
c.InsertConversationActivity(models.ActivityParticipantAdded, conversationUUID, participantName, systemUser)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// getConversationTags retrieves the tags associated with a conversation.
|
|
func (c *Manager) getConversationTags(uuid string) ([]string, error) {
|
|
var tags []string
|
|
if err := c.q.GetConversationTags.Select(&tags, uuid); err != nil {
|
|
c.lo.Error("error fetching conversation tags", "error", err)
|
|
return tags, envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
return tags, nil
|
|
}
|
|
|
|
// makeConversationsListQuery prepares a SQL query string for conversations list
|
|
// viewingUserID is used as $1 for per-agent unread count calculation
|
|
// $2 is includeMentions bool for conditional mentioned_message_uuid column
|
|
func (c *Manager) makeConversationsListQuery(viewingUserID, userID int, teamIDs []int, listTypes []string, baseQuery, order, orderBy string, page, pageSize int, filtersJSON string) (string, []interface{}, error) {
|
|
includeMentions := slices.Contains(listTypes, models.MentionedConversations)
|
|
qArgs := []any{viewingUserID, includeMentions}
|
|
|
|
// Set defaults
|
|
if orderBy == "" {
|
|
orderBy = "conversations.last_message_at"
|
|
}
|
|
if order == "" {
|
|
order = "DESC"
|
|
}
|
|
if filtersJSON == "" {
|
|
filtersJSON = "[]"
|
|
}
|
|
|
|
// Validate inputs
|
|
if pageSize > conversationsListMaxPageSize {
|
|
pageSize = conversationsListMaxPageSize
|
|
}
|
|
|
|
if len(listTypes) == 0 {
|
|
return "", nil, fmt.Errorf("no conversation list types specified")
|
|
}
|
|
|
|
// Parse filters to extract tag filters
|
|
var (
|
|
filters []dbutil.Filter
|
|
tagFilters []dbutil.Filter
|
|
remainingFilters []dbutil.Filter
|
|
)
|
|
if filtersJSON != "" && filtersJSON != "[]" {
|
|
if err := json.Unmarshal([]byte(filtersJSON), &filters); err != nil {
|
|
return "", nil, fmt.Errorf("invalid filters JSON: %w", err)
|
|
}
|
|
|
|
// Separate tag filters from other filters
|
|
for _, f := range filters {
|
|
if f.Field == "tags" && (f.Operator == "contains" || f.Operator == "not contains" || f.Operator == "set" || f.Operator == "not set") {
|
|
tagFilters = append(tagFilters, f)
|
|
} else {
|
|
remainingFilters = append(remainingFilters, f)
|
|
}
|
|
}
|
|
|
|
// Update filtersJSON with remaining filters for the generic builder
|
|
if len(remainingFilters) > 0 {
|
|
b, _ := json.Marshal(remainingFilters)
|
|
filtersJSON = string(b)
|
|
} else {
|
|
filtersJSON = "[]"
|
|
}
|
|
}
|
|
|
|
// Prepare the conditions based on the list types.
|
|
conditions := []string{}
|
|
for _, lt := range listTypes {
|
|
switch lt {
|
|
case models.AssignedConversations:
|
|
conditions = append(conditions, fmt.Sprintf("conversations.assigned_user_id = $%d", len(qArgs)+1))
|
|
qArgs = append(qArgs, userID)
|
|
case models.UnassignedConversations:
|
|
conditions = append(conditions, "conversations.assigned_user_id IS NULL AND conversations.assigned_team_id IS NULL")
|
|
case models.TeamUnassignedConversations:
|
|
placeholders := make([]string, len(teamIDs))
|
|
for i := range teamIDs {
|
|
placeholders[i] = fmt.Sprintf("$%d", len(qArgs)+i+1)
|
|
}
|
|
conditions = append(conditions, fmt.Sprintf("(conversations.assigned_team_id IN (%s) AND conversations.assigned_user_id IS NULL)", strings.Join(placeholders, ",")))
|
|
for _, id := range teamIDs {
|
|
qArgs = append(qArgs, id)
|
|
}
|
|
case models.TeamAllConversations:
|
|
placeholders := make([]string, len(teamIDs))
|
|
for i := range teamIDs {
|
|
placeholders[i] = fmt.Sprintf("$%d", len(qArgs)+i+1)
|
|
}
|
|
conditions = append(conditions, fmt.Sprintf("(conversations.assigned_team_id IN (%s))", strings.Join(placeholders, ",")))
|
|
for _, id := range teamIDs {
|
|
qArgs = append(qArgs, id)
|
|
}
|
|
case models.AllConversations:
|
|
// No conditions needed for all conversations.
|
|
case models.MentionedConversations:
|
|
// Filter to only conversations where user is mentioned (directly or via team)
|
|
conditions = append(conditions, `conversations.id IN (
|
|
SELECT cm.conversation_id
|
|
FROM conversation_mentions cm
|
|
WHERE cm.mentioned_user_id = $1
|
|
OR EXISTS(
|
|
SELECT 1 FROM team_members tm
|
|
WHERE tm.team_id = cm.mentioned_team_id AND tm.user_id = $1
|
|
)
|
|
)`)
|
|
default:
|
|
return "", nil, fmt.Errorf("unknown conversation type: %s", lt)
|
|
}
|
|
}
|
|
|
|
// Build the base query with list type conditions
|
|
var whereClause string
|
|
if len(conditions) > 0 {
|
|
whereClause = "AND (" + strings.Join(conditions, " OR ") + ")"
|
|
}
|
|
|
|
// Add tag filter conditions
|
|
// TODO: Evaluate - https://github.com/Masterminds/squirrel when required.
|
|
for _, tf := range tagFilters {
|
|
switch tf.Operator {
|
|
case "contains", "not contains":
|
|
var tagIDs []int
|
|
if err := json.Unmarshal([]byte(tf.Value), &tagIDs); err != nil {
|
|
return "", nil, fmt.Errorf("invalid tag IDs in filter: %w", err)
|
|
}
|
|
if len(tagIDs) > 0 {
|
|
paramIdx := len(qArgs) + 1
|
|
switch tf.Operator {
|
|
case "contains":
|
|
// Has any of the tags
|
|
tagCondition := fmt.Sprintf(` AND conversations.id IN (
|
|
SELECT DISTINCT conversation_id
|
|
FROM conversation_tags
|
|
WHERE tag_id = ANY($%d::int[])
|
|
)`, paramIdx)
|
|
whereClause += tagCondition
|
|
case "not contains":
|
|
// Doesn't have any of the tags
|
|
tagCondition := fmt.Sprintf(` AND conversations.id NOT IN (
|
|
SELECT DISTINCT conversation_id
|
|
FROM conversation_tags
|
|
WHERE tag_id = ANY($%d::int[])
|
|
)`, paramIdx)
|
|
whereClause += tagCondition
|
|
}
|
|
qArgs = append(qArgs, pq.Array(tagIDs))
|
|
}
|
|
case "set":
|
|
// Has any tags at all
|
|
whereClause += ` AND EXISTS (
|
|
SELECT 1 FROM conversation_tags
|
|
WHERE conversation_id = conversations.id
|
|
)`
|
|
case "not set":
|
|
// Has no tags at all
|
|
whereClause += ` AND NOT EXISTS (
|
|
SELECT 1 FROM conversation_tags
|
|
WHERE conversation_id = conversations.id
|
|
)`
|
|
}
|
|
}
|
|
|
|
baseQuery = fmt.Sprintf(baseQuery, whereClause)
|
|
|
|
return dbutil.BuildPaginatedQuery(baseQuery, qArgs, dbutil.PaginationOptions{
|
|
Order: order,
|
|
OrderBy: orderBy,
|
|
Page: page,
|
|
PageSize: pageSize,
|
|
}, filtersJSON, dbutil.AllowedFields{
|
|
"conversations": conversationsAllowedFields,
|
|
"conversation_statuses": conversationStatusAllowedFields,
|
|
"users": usersAllowedFields,
|
|
})
|
|
}
|
|
|
|
// ProcessCSATStatus processes messages and adds CSAT submission status for CSAT messages.
|
|
func (m *Manager) ProcessCSATStatus(messages []models.Message) {
|
|
for i := range messages {
|
|
msg := &messages[i]
|
|
csatUUID := msg.ExtractCSATUUID()
|
|
if csatUUID == "" {
|
|
continue
|
|
}
|
|
|
|
var (
|
|
isSubmitted bool
|
|
rating int
|
|
feedback string
|
|
)
|
|
csat, err := m.csatStore.Get(csatUUID)
|
|
if err == nil && csat.ResponseTimestamp.Valid {
|
|
isSubmitted = true
|
|
rating = csat.Rating
|
|
if csat.Feedback.Valid {
|
|
feedback = csat.Feedback.String
|
|
}
|
|
}
|
|
msg.CensorCSATContentWithStatus(isSubmitted, csatUUID, rating, feedback)
|
|
}
|
|
}
|
|
|
|
// BuildWidgetConversationView builds the conversation view data for widget clients
|
|
func (m *Manager) BuildWidgetConversationView(conversation models.Conversation) (WidgetConversationView, error) {
|
|
view := WidgetConversationView{
|
|
UUID: conversation.UUID,
|
|
Status: conversation.Status.String,
|
|
Assignee: nil,
|
|
}
|
|
|
|
// Fetch assignee details if assigned
|
|
if conversation.AssignedUserID.Int > 0 {
|
|
assignee, err := m.userStore.GetAgent(conversation.AssignedUserID.Int, "")
|
|
if err != nil {
|
|
m.lo.Error("error fetching conversation assignee for widget", "conversation_uuid", conversation.UUID, "error", err)
|
|
} else {
|
|
// Convert assignee avatar URL to signed URL if needed
|
|
if assignee.AvatarURL.Valid && assignee.AvatarURL.String != "" {
|
|
avatarPath := assignee.AvatarURL.String
|
|
if strings.HasPrefix(avatarPath, "/uploads/") {
|
|
avatarUUID := strings.TrimPrefix(avatarPath, "/uploads/")
|
|
assignee.AvatarURL = null.StringFrom(m.mediaStore.GetSignedURL(avatarUUID))
|
|
}
|
|
}
|
|
|
|
view.Assignee = map[string]any{
|
|
"id": assignee.ID,
|
|
"first_name": assignee.FirstName,
|
|
"last_name": assignee.LastName,
|
|
"avatar_url": assignee.AvatarURL,
|
|
"availability_status": assignee.AvailabilityStatus,
|
|
"type": assignee.Type,
|
|
"active_at": assignee.LastActiveAt,
|
|
}
|
|
}
|
|
}
|
|
|
|
// Calculate business hours info
|
|
businessHoursID, utcOffset := m.calculateBusinessHoursInfo(conversation)
|
|
if businessHoursID != nil {
|
|
view.BusinessHoursID = businessHoursID
|
|
}
|
|
if utcOffset != nil {
|
|
view.WorkingHoursUTCOffset = utcOffset
|
|
}
|
|
|
|
return view, nil
|
|
}
|
|
|
|
// BuildWidgetConversationResponse builds the full conversation response for widget including messages
|
|
func (m *Manager) BuildWidgetConversationResponse(conversation models.Conversation, includeMessages bool) (WidgetConversationResponse, error) {
|
|
var resp = WidgetConversationResponse{}
|
|
|
|
chatConversation, err := m.GetChatConversation(conversation.UUID)
|
|
if err != nil {
|
|
return resp, err
|
|
}
|
|
resp.Conversation = chatConversation
|
|
|
|
// Build messages if requested
|
|
if includeMessages {
|
|
private := false
|
|
// Fetch last 400 messages.
|
|
messages, _, err := m.GetConversationMessages(conversation.UUID, 1, 400, &private, []string{models.MessageIncoming, models.MessageOutgoing})
|
|
if err != nil {
|
|
m.lo.Error("error fetching conversation messages", "conversation_uuid", conversation.UUID, "error", err)
|
|
return resp, envelope.NewError(envelope.GeneralError, "Error fetching messages", nil)
|
|
}
|
|
|
|
m.ProcessCSATStatus(messages)
|
|
|
|
// Generate signed URLs for all attachments.
|
|
chatMessages := make([]models.ChatMessage, len(messages))
|
|
for i, msg := range messages {
|
|
m.SignAvatarURL(&msg.Author.AvatarURL)
|
|
attachments := msg.Attachments
|
|
for j := range attachments {
|
|
attachments[j].URL = m.mediaStore.GetSignedURL(attachments[j].UUID)
|
|
}
|
|
|
|
chatMessages[i] = models.ChatMessage{
|
|
UUID: msg.UUID,
|
|
Status: msg.Status,
|
|
CreatedAt: msg.CreatedAt,
|
|
Content: msg.Content,
|
|
TextContent: msg.TextContent,
|
|
ConversationUUID: msg.ConversationUUID,
|
|
Meta: msg.Meta,
|
|
Author: msg.Author,
|
|
Attachments: attachments,
|
|
}
|
|
}
|
|
resp.Messages = chatMessages
|
|
}
|
|
|
|
// Calculate business hours info
|
|
businessHoursID, utcOffset := m.calculateBusinessHoursInfo(conversation)
|
|
resp.BusinessHoursID = businessHoursID
|
|
resp.WorkingHoursUTCOffset = utcOffset
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
// calculateBusinessHoursInfo calculates business hours ID and UTC offset for a conversation
|
|
func (m *Manager) calculateBusinessHoursInfo(conversation models.Conversation) (*int, *int) {
|
|
var (
|
|
businessHoursID *int
|
|
timezone string
|
|
utcOffset *int
|
|
)
|
|
|
|
// Check if conversation is assigned to a team with business hours
|
|
if conversation.AssignedTeamID.Valid {
|
|
team, err := m.teamStore.Get(conversation.AssignedTeamID.Int)
|
|
if err != nil {
|
|
m.lo.Error("error fetching team for business hours info", "team_id", conversation.AssignedTeamID.Int, "error", err)
|
|
} else if team.BusinessHoursID.Valid {
|
|
businessHoursID = &team.BusinessHoursID.Int
|
|
timezone = team.Timezone
|
|
}
|
|
}
|
|
|
|
// Fallback to general settings if no team business hours or no timezone
|
|
if businessHoursID == nil || timezone == "" {
|
|
out, err := m.settingsStore.GetByPrefix("app")
|
|
if err == nil {
|
|
var settings map[string]any
|
|
err := json.Unmarshal([]byte(out), &settings)
|
|
if err != nil {
|
|
m.lo.Error("error unmarshalling settings", "error", err)
|
|
} else {
|
|
if businessHoursID == nil {
|
|
if bhIDStr, ok := settings["app.business_hours_id"].(string); ok && bhIDStr != "" {
|
|
if bhID, err := strconv.Atoi(bhIDStr); err == nil {
|
|
businessHoursID = &bhID
|
|
}
|
|
}
|
|
}
|
|
if timezone == "" {
|
|
if tz, ok := settings["app.timezone"].(string); ok {
|
|
timezone = tz
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Calculate UTC offset for the timezone
|
|
if timezone != "" {
|
|
loc, err := time.LoadLocation(timezone)
|
|
if err != nil {
|
|
m.lo.Error("error loading location for timezone", "timezone", timezone, "error", err)
|
|
} else {
|
|
_, offset := time.Now().In(loc).Zone()
|
|
offsetMinutes := offset / 60
|
|
utcOffset = &offsetMinutes
|
|
}
|
|
}
|
|
|
|
return businessHoursID, utcOffset
|
|
}
|