mirror of
				https://github.com/abhinavxd/libredesk.git
				synced 2025-11-03 21:43:35 +00:00 
			
		
		
		
	Fixes for next response time sla metric
This commit is contained in:
		@@ -7,7 +7,6 @@ import (
 | 
			
		||||
	"github.com/abhinavxd/libredesk/internal/automation/models"
 | 
			
		||||
	"github.com/abhinavxd/libredesk/internal/envelope"
 | 
			
		||||
	medModels "github.com/abhinavxd/libredesk/internal/media/models"
 | 
			
		||||
	"github.com/abhinavxd/libredesk/internal/sla"
 | 
			
		||||
	"github.com/valyala/fasthttp"
 | 
			
		||||
	"github.com/zerodha/fastglue"
 | 
			
		||||
)
 | 
			
		||||
@@ -173,10 +172,6 @@ func handleSendMessage(r *fastglue.Request) error {
 | 
			
		||||
		}
 | 
			
		||||
		// Evaluate automation rules.
 | 
			
		||||
		app.automation.EvaluateConversationUpdateRules(cuuid, models.EventConversationMessageOutgoing)
 | 
			
		||||
 | 
			
		||||
		// Set `met at` timestamp for next response SLA metric as the agent has sent a message.
 | 
			
		||||
		app.sla.SetLatestSLAEventMetAt(conv.AppliedSLAID.Int, sla.MetricNextResponse)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return r.SendEnvelope(true)
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -638,9 +638,11 @@ export const useConversationStore = defineStore('conversation', () => {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  function updateConversationProp (update) {
 | 
			
		||||
    // Update the current conversation if it matches the UUID.
 | 
			
		||||
    if (conversation.data?.uuid === update.uuid) {
 | 
			
		||||
      conversation.data[update.prop] = update.value
 | 
			
		||||
    }
 | 
			
		||||
    // Update the conversation in the list if it exists.
 | 
			
		||||
    const existingConversation = conversations?.data?.find(c => c.uuid === update.uuid)
 | 
			
		||||
    if (existingConversation) {
 | 
			
		||||
      existingConversation[update.prop] = update.value
 | 
			
		||||
 
 | 
			
		||||
@@ -82,7 +82,8 @@ type Manager struct {
 | 
			
		||||
 | 
			
		||||
type slaStore interface {
 | 
			
		||||
	ApplySLA(startTime time.Time, conversationID, assignedTeamID, slaID int) (slaModels.SLAPolicy, error)
 | 
			
		||||
	CreateNextResponseSLAEvent(conversationID, appliedSLAID, slaPolicyID, assignedTeamID int) error
 | 
			
		||||
	CreateNextResponseSLAEvent(conversationID, appliedSLAID, slaPolicyID, assignedTeamID int) (time.Time, error)
 | 
			
		||||
	SetLatestSLAEventMetAt(appliedSLAID int, metric string) (time.Time, error)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type statusStore interface {
 | 
			
		||||
@@ -592,6 +593,19 @@ func (c *Manager) UpdateConversationStatus(uuid string, statusID int, status, sn
 | 
			
		||||
		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.Ts("globals.messages.errorFetching", "name", "{globals.terms.conversation}"), nil)
 | 
			
		||||
	}
 | 
			
		||||
	oldStatus := conversationBeforeChange.Status.String
 | 
			
		||||
 | 
			
		||||
	// Status not changed? return early.
 | 
			
		||||
	if oldStatus == status {
 | 
			
		||||
		c.lo.Info("conversation status is unchanged", "uuid", uuid, "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)
 | 
			
		||||
@@ -605,6 +619,16 @@ func (c *Manager) UpdateConversationStatus(uuid string, statusID int, status, sn
 | 
			
		||||
 | 
			
		||||
	// Broadcast updates using websocket.
 | 
			
		||||
	c.BroadcastConversationUpdate(uuid, "status", status)
 | 
			
		||||
 | 
			
		||||
	// Broadcast `resolved_at` if the status is changed to resolved, `resolved_at` is set only once when the conversation is resolved for the first time.
 | 
			
		||||
	// Subsequent status changes to resolved will not update the `resolved_at` field.
 | 
			
		||||
	if oldStatus != models.StatusResolved && status == models.StatusResolved {
 | 
			
		||||
		resolvedAt := conversationBeforeChange.ResolvedAt.Time
 | 
			
		||||
		if resolvedAt.IsZero() {
 | 
			
		||||
			resolvedAt = time.Now()
 | 
			
		||||
		}
 | 
			
		||||
		c.BroadcastConversationUpdate(uuid, "resolved_at", resolvedAt.Format(time.RFC3339))
 | 
			
		||||
	}
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -19,6 +19,7 @@ import (
 | 
			
		||||
	"github.com/abhinavxd/libredesk/internal/image"
 | 
			
		||||
	"github.com/abhinavxd/libredesk/internal/inbox"
 | 
			
		||||
	mmodels "github.com/abhinavxd/libredesk/internal/media/models"
 | 
			
		||||
	"github.com/abhinavxd/libredesk/internal/sla"
 | 
			
		||||
	"github.com/abhinavxd/libredesk/internal/stringutil"
 | 
			
		||||
	umodels "github.com/abhinavxd/libredesk/internal/user/models"
 | 
			
		||||
	"github.com/lib/pq"
 | 
			
		||||
@@ -180,7 +181,7 @@ func (m *Manager) sendOutgoingMessage(message models.Message) {
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Update status of the message.
 | 
			
		||||
	// Update status.
 | 
			
		||||
	m.UpdateMessageStatus(message.UUID, models.MessageStatusSent)
 | 
			
		||||
 | 
			
		||||
	// Update first and last reply time if the sender is not the system user.
 | 
			
		||||
@@ -188,6 +189,15 @@ func (m *Manager) sendOutgoingMessage(message models.Message) {
 | 
			
		||||
	if systemUser, err := m.userStore.GetSystemUser(); err == nil && message.SenderID != systemUser.ID {
 | 
			
		||||
		m.UpdateConversationFirstReplyAt(message.ConversationUUID, message.ConversationID, time.Now())
 | 
			
		||||
		m.UpdateConversationLastReplyAt(message.ConversationUUID, message.ConversationID, time.Now())
 | 
			
		||||
		// Set `met_at` timestamp for next response SLA event if event exists and not already met.
 | 
			
		||||
		if message.ConversationAppliedSLAID.Int > 0 {
 | 
			
		||||
			metAt, err := m.slaStore.SetLatestSLAEventMetAt(message.ConversationAppliedSLAID.Int, sla.MetricNextResponse)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				m.lo.Error("error setting next response SLA event met at", "conversation_id", message.ConversationID, "error", err)
 | 
			
		||||
			} else if !metAt.IsZero() {
 | 
			
		||||
				m.BroadcastConversationUpdate(message.ConversationUUID, "next_response_met_at", metAt.Format(time.RFC3339))
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	} else if err != nil {
 | 
			
		||||
		m.lo.Error("error fetching system user for updating first reply time", "error", err)
 | 
			
		||||
	}
 | 
			
		||||
@@ -276,15 +286,15 @@ func (m *Manager) GetMessage(uuid string) (models.Message, error) {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// UpdateMessageStatus updates the status of a message.
 | 
			
		||||
func (m *Manager) UpdateMessageStatus(uuid string, status string) error {
 | 
			
		||||
	if _, err := m.q.UpdateMessageStatus.Exec(status, uuid); err != nil {
 | 
			
		||||
		m.lo.Error("error updating message status", "error", err, "uuid", uuid)
 | 
			
		||||
func (m *Manager) UpdateMessageStatus(messageUUID string, status string) error {
 | 
			
		||||
	if _, err := m.q.UpdateMessageStatus.Exec(status, messageUUID); err != nil {
 | 
			
		||||
		m.lo.Error("error updating message status", "message_uuid", messageUUID, "error", err)
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Broadcast messge status update to all conversation subscribers.
 | 
			
		||||
	conversationUUID, _ := m.getConversationUUIDFromMessageUUID(uuid)
 | 
			
		||||
	m.BroadcastMessageUpdate(conversationUUID, uuid, "status" /*property*/, status)
 | 
			
		||||
	// Broadcast message status update to all conversation subscribers.
 | 
			
		||||
	conversationUUID, _ := m.getConversationUUIDFromMessageUUID(messageUUID)
 | 
			
		||||
	m.BroadcastMessageUpdate(conversationUUID, messageUUID, "status" /*property*/, status)
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -576,7 +586,7 @@ func (m *Manager) processIncomingMessage(in models.IncomingMessage) error {
 | 
			
		||||
	// Trigger automations on incoming message event.
 | 
			
		||||
	m.automation.EvaluateConversationUpdateRules(in.Message.ConversationUUID, amodels.EventConversationMessageIncoming)
 | 
			
		||||
 | 
			
		||||
	// Create SLA event for next response if SLA is applied and has next response time set, subsequent agent replies will mark this event as met.
 | 
			
		||||
	// Create SLA event for next response if a SLA is applied and has next response time set, subsequent agent replies will mark this event as met.
 | 
			
		||||
	conversation, err := m.GetConversation(in.Message.ConversationID, "")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		m.lo.Error("error fetching conversation", "conversation_id", in.Message.ConversationID, "error", err)
 | 
			
		||||
@@ -585,9 +595,15 @@ func (m *Manager) processIncomingMessage(in models.IncomingMessage) error {
 | 
			
		||||
		m.lo.Info("no SLA policy applied to conversation, skipping next response SLA event creation")
 | 
			
		||||
		return nil
 | 
			
		||||
	}
 | 
			
		||||
	if err := m.slaStore.CreateNextResponseSLAEvent(conversation.ID, conversation.AppliedSLAID.Int, conversation.SLAPolicyID.Int, conversation.AssignedTeamID.Int); err != nil {
 | 
			
		||||
	if deadline, err := m.slaStore.CreateNextResponseSLAEvent(conversation.ID, conversation.AppliedSLAID.Int, conversation.SLAPolicyID.Int, conversation.AssignedTeamID.Int); err != nil {
 | 
			
		||||
		m.lo.Error("error creating next response SLA event", "conversation_id", conversation.ID, "error", err)
 | 
			
		||||
		return fmt.Errorf("creating next response SLA event: %w", err)
 | 
			
		||||
	} else {
 | 
			
		||||
		if !deadline.IsZero() {
 | 
			
		||||
			m.lo.Debug("next response SLA event created", "conversation_id", conversation.ID, "deadline", deadline, "applied_sla_id", conversation.AppliedSLAID.Int, "sla_policy_id", conversation.SLAPolicyID.Int)
 | 
			
		||||
			m.BroadcastConversationUpdate(in.Message.ConversationUUID, "next_response_deadline_at", deadline.Format(time.RFC3339))
 | 
			
		||||
			// Clear next response met at timestamp as a new SLA event is created.
 | 
			
		||||
			m.BroadcastConversationUpdate(in.Message.ConversationUUID, "next_response_met_at", nil)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -53,51 +53,50 @@ var (
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type Conversation struct {
 | 
			
		||||
	ID                         int             `db:"id" json:"id,omitempty"`
 | 
			
		||||
	CreatedAt                  time.Time       `db:"created_at" json:"created_at"`
 | 
			
		||||
	UpdatedAt                  time.Time       `db:"updated_at" json:"updated_at"`
 | 
			
		||||
	UUID                       string          `db:"uuid" json:"uuid"`
 | 
			
		||||
	ContactID                  int             `db:"contact_id" json:"contact_id"`
 | 
			
		||||
	InboxID                    int             `db:"inbox_id" json:"inbox_id,omitempty"`
 | 
			
		||||
	ClosedAt                   null.Time       `db:"closed_at" json:"closed_at,omitempty"`
 | 
			
		||||
	ResolvedAt                 null.Time       `db:"resolved_at" json:"resolved_at,omitempty"`
 | 
			
		||||
	ReferenceNumber            string          `db:"reference_number" json:"reference_number,omitempty"`
 | 
			
		||||
	Priority                   null.String     `db:"priority" json:"priority"`
 | 
			
		||||
	PriorityID                 null.Int        `db:"priority_id" json:"priority_id"`
 | 
			
		||||
	Status                     null.String     `db:"status" json:"status"`
 | 
			
		||||
	StatusID                   null.Int        `db:"status_id" json:"status_id"`
 | 
			
		||||
	FirstReplyAt               null.Time       `db:"first_reply_at" json:"first_reply_at"`
 | 
			
		||||
	LastReplyAt                null.Time       `db:"last_reply_at" json:"last_reply_at"`
 | 
			
		||||
	AssignedUserID             null.Int        `db:"assigned_user_id" json:"assigned_user_id"`
 | 
			
		||||
	AssignedTeamID             null.Int        `db:"assigned_team_id" json:"assigned_team_id"`
 | 
			
		||||
	AssigneeLastSeenAt         null.Time       `db:"assignee_last_seen_at" json:"assignee_last_seen_at"`
 | 
			
		||||
	WaitingSince               null.Time       `db:"waiting_since" json:"waiting_since"`
 | 
			
		||||
	Subject                    null.String     `db:"subject" json:"subject"`
 | 
			
		||||
	UnreadMessageCount         int             `db:"unread_message_count" json:"unread_message_count"`
 | 
			
		||||
	InboxMail                  string          `db:"inbox_mail" json:"inbox_mail"`
 | 
			
		||||
	InboxName                  string          `db:"inbox_name" json:"inbox_name"`
 | 
			
		||||
	InboxChannel               string          `db:"inbox_channel" json:"inbox_channel"`
 | 
			
		||||
	Tags                       null.JSON       `db:"tags" json:"tags"`
 | 
			
		||||
	Meta                       pq.StringArray  `db:"meta" json:"meta"`
 | 
			
		||||
	CustomAttributes           json.RawMessage `db:"custom_attributes" json:"custom_attributes"`
 | 
			
		||||
	LastMessageAt              null.Time       `db:"last_message_at" json:"last_message_at"`
 | 
			
		||||
	LastMessage                null.String     `db:"last_message" json:"last_message"`
 | 
			
		||||
	LastMessageSender          null.String     `db:"last_message_sender" json:"last_message_sender"`
 | 
			
		||||
	Contact                    umodels.User    `db:"contact" json:"contact"`
 | 
			
		||||
	AppliedSLAID               null.Int        `db:"applied_sla_id" json:"applied_sla_id"`
 | 
			
		||||
	SLAPolicyID                null.Int        `db:"sla_policy_id" json:"sla_policy_id"`
 | 
			
		||||
	SlaPolicyName              null.String     `db:"sla_policy_name" json:"sla_policy_name"`
 | 
			
		||||
	NextSLADeadlineAt          null.Time       `db:"next_sla_deadline_at" json:"next_sla_deadline_at"`
 | 
			
		||||
	FirstResponseDueAt         null.Time       `db:"first_response_deadline_at" json:"first_response_deadline_at"`
 | 
			
		||||
	ResolutionDueAt            null.Time       `db:"resolution_deadline_at" json:"resolution_deadline_at"`
 | 
			
		||||
	NextResponseDueAt          null.Time       `db:"next_response_deadline_at" json:"next_response_deadline_at"`
 | 
			
		||||
	NextResponseSLAEventStatus null.String     `db:"next_response_sla_event_status" json:"next_response_sla_event_status"`
 | 
			
		||||
	SLAStatus                  null.String     `db:"sla_status" json:"sla_status"`
 | 
			
		||||
	To                         json.RawMessage `db:"to" json:"to"`
 | 
			
		||||
	BCC                        json.RawMessage `db:"bcc" json:"bcc"`
 | 
			
		||||
	CC                         json.RawMessage `db:"cc" json:"cc"`
 | 
			
		||||
	PreviousConversations      []Conversation  `db:"-" json:"previous_conversations"`
 | 
			
		||||
	Total                      int             `db:"total" json:"-"`
 | 
			
		||||
	ID                    int             `db:"id" json:"id,omitempty"`
 | 
			
		||||
	CreatedAt             time.Time       `db:"created_at" json:"created_at"`
 | 
			
		||||
	UpdatedAt             time.Time       `db:"updated_at" json:"updated_at"`
 | 
			
		||||
	UUID                  string          `db:"uuid" json:"uuid"`
 | 
			
		||||
	ContactID             int             `db:"contact_id" json:"contact_id"`
 | 
			
		||||
	InboxID               int             `db:"inbox_id" json:"inbox_id,omitempty"`
 | 
			
		||||
	ClosedAt              null.Time       `db:"closed_at" json:"closed_at,omitempty"`
 | 
			
		||||
	ResolvedAt            null.Time       `db:"resolved_at" json:"resolved_at,omitempty"`
 | 
			
		||||
	ReferenceNumber       string          `db:"reference_number" json:"reference_number,omitempty"`
 | 
			
		||||
	Priority              null.String     `db:"priority" json:"priority"`
 | 
			
		||||
	PriorityID            null.Int        `db:"priority_id" json:"priority_id"`
 | 
			
		||||
	Status                null.String     `db:"status" json:"status"`
 | 
			
		||||
	StatusID              null.Int        `db:"status_id" json:"status_id"`
 | 
			
		||||
	FirstReplyAt          null.Time       `db:"first_reply_at" json:"first_reply_at"`
 | 
			
		||||
	LastReplyAt           null.Time       `db:"last_reply_at" json:"last_reply_at"`
 | 
			
		||||
	AssignedUserID        null.Int        `db:"assigned_user_id" json:"assigned_user_id"`
 | 
			
		||||
	AssignedTeamID        null.Int        `db:"assigned_team_id" json:"assigned_team_id"`
 | 
			
		||||
	AssigneeLastSeenAt    null.Time       `db:"assignee_last_seen_at" json:"assignee_last_seen_at"`
 | 
			
		||||
	WaitingSince          null.Time       `db:"waiting_since" json:"waiting_since"`
 | 
			
		||||
	Subject               null.String     `db:"subject" json:"subject"`
 | 
			
		||||
	UnreadMessageCount    int             `db:"unread_message_count" json:"unread_message_count"`
 | 
			
		||||
	InboxMail             string          `db:"inbox_mail" json:"inbox_mail"`
 | 
			
		||||
	InboxName             string          `db:"inbox_name" json:"inbox_name"`
 | 
			
		||||
	InboxChannel          string          `db:"inbox_channel" json:"inbox_channel"`
 | 
			
		||||
	Tags                  null.JSON       `db:"tags" json:"tags"`
 | 
			
		||||
	Meta                  pq.StringArray  `db:"meta" json:"meta"`
 | 
			
		||||
	CustomAttributes      json.RawMessage `db:"custom_attributes" json:"custom_attributes"`
 | 
			
		||||
	LastMessageAt         null.Time       `db:"last_message_at" json:"last_message_at"`
 | 
			
		||||
	LastMessage           null.String     `db:"last_message" json:"last_message"`
 | 
			
		||||
	LastMessageSender     null.String     `db:"last_message_sender" json:"last_message_sender"`
 | 
			
		||||
	Contact               umodels.User    `db:"contact" json:"contact"`
 | 
			
		||||
	AppliedSLAID          null.Int        `db:"applied_sla_id" json:"applied_sla_id"`
 | 
			
		||||
	SLAPolicyID           null.Int        `db:"sla_policy_id" json:"sla_policy_id"`
 | 
			
		||||
	SlaPolicyName         null.String     `db:"sla_policy_name" json:"sla_policy_name"`
 | 
			
		||||
	NextSLADeadlineAt     null.Time       `db:"next_sla_deadline_at" json:"next_sla_deadline_at"`
 | 
			
		||||
	FirstResponseDueAt    null.Time       `db:"first_response_deadline_at" json:"first_response_deadline_at"`
 | 
			
		||||
	ResolutionDueAt       null.Time       `db:"resolution_deadline_at" json:"resolution_deadline_at"`
 | 
			
		||||
	NextResponseDueAt     null.Time       `db:"next_response_deadline_at" json:"next_response_deadline_at"`
 | 
			
		||||
	NextResponseMetAt     null.Time       `db:"next_response_met_at" json:"next_response_met_at"`
 | 
			
		||||
	To                    json.RawMessage `db:"to" json:"to"`
 | 
			
		||||
	BCC                   json.RawMessage `db:"bcc" json:"bcc"`
 | 
			
		||||
	CC                    json.RawMessage `db:"cc" json:"cc"`
 | 
			
		||||
	PreviousConversations []Conversation  `db:"-" json:"previous_conversations"`
 | 
			
		||||
	Total                 int             `db:"total" json:"-"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type ConversationParticipant struct {
 | 
			
		||||
@@ -121,37 +120,38 @@ type NewConversationsStats struct {
 | 
			
		||||
 | 
			
		||||
// Message represents a message in a conversation
 | 
			
		||||
type Message struct {
 | 
			
		||||
	ID               int                    `db:"id" json:"id,omitempty"`
 | 
			
		||||
	CreatedAt        time.Time              `db:"created_at" json:"created_at"`
 | 
			
		||||
	UpdatedAt        time.Time              `db:"updated_at" json:"updated_at"`
 | 
			
		||||
	UUID             string                 `db:"uuid" json:"uuid"`
 | 
			
		||||
	Type             string                 `db:"type" json:"type"`
 | 
			
		||||
	Status           string                 `db:"status" json:"status"`
 | 
			
		||||
	ConversationID   int                    `db:"conversation_id" json:"conversation_id"`
 | 
			
		||||
	Content          string                 `db:"content" json:"content"`
 | 
			
		||||
	TextContent      string                 `db:"text_content" json:"text_content"`
 | 
			
		||||
	ContentType      string                 `db:"content_type" json:"content_type"`
 | 
			
		||||
	Private          bool                   `db:"private" json:"private"`
 | 
			
		||||
	SourceID         null.String            `db:"source_id" json:"-"`
 | 
			
		||||
	SenderID         int                    `db:"sender_id" json:"sender_id"`
 | 
			
		||||
	SenderType       string                 `db:"sender_type" json:"sender_type"`
 | 
			
		||||
	InboxID          int                    `db:"inbox_id" json:"-"`
 | 
			
		||||
	Meta             json.RawMessage        `db:"meta" json:"meta"`
 | 
			
		||||
	Attachments      attachment.Attachments `db:"attachments" json:"attachments"`
 | 
			
		||||
	ConversationUUID string                 `db:"conversation_uuid" json:"-"`
 | 
			
		||||
	From             string                 `db:"from"  json:"-"`
 | 
			
		||||
	Subject          string                 `db:"subject" json:"-"`
 | 
			
		||||
	Channel          string                 `db:"channel" json:"-"`
 | 
			
		||||
	To               pq.StringArray         `db:"to"  json:"-"`
 | 
			
		||||
	CC               pq.StringArray         `db:"cc" json:"-"`
 | 
			
		||||
	BCC              pq.StringArray         `db:"bcc" json:"-"`
 | 
			
		||||
	References       []string               `json:"-"`
 | 
			
		||||
	InReplyTo        string                 `json:"-"`
 | 
			
		||||
	Headers          textproto.MIMEHeader   `json:"-"`
 | 
			
		||||
	AltContent       string                 `db:"-" json:"-"`
 | 
			
		||||
	Media            []mmodels.Media        `db:"-" json:"-"`
 | 
			
		||||
	IsCSAT           bool                   `db:"-" json:"-"`
 | 
			
		||||
	Total            int                    `db:"total" json:"-"`
 | 
			
		||||
	ID                       int                    `db:"id" json:"id,omitempty"`
 | 
			
		||||
	CreatedAt                time.Time              `db:"created_at" json:"created_at"`
 | 
			
		||||
	UpdatedAt                time.Time              `db:"updated_at" json:"updated_at"`
 | 
			
		||||
	UUID                     string                 `db:"uuid" json:"uuid"`
 | 
			
		||||
	Type                     string                 `db:"type" json:"type"`
 | 
			
		||||
	Status                   string                 `db:"status" json:"status"`
 | 
			
		||||
	ConversationID           int                    `db:"conversation_id" json:"conversation_id"`
 | 
			
		||||
	Content                  string                 `db:"content" json:"content"`
 | 
			
		||||
	TextContent              string                 `db:"text_content" json:"text_content"`
 | 
			
		||||
	ContentType              string                 `db:"content_type" json:"content_type"`
 | 
			
		||||
	Private                  bool                   `db:"private" json:"private"`
 | 
			
		||||
	SourceID                 null.String            `db:"source_id" json:"-"`
 | 
			
		||||
	SenderID                 int                    `db:"sender_id" json:"sender_id"`
 | 
			
		||||
	SenderType               string                 `db:"sender_type" json:"sender_type"`
 | 
			
		||||
	InboxID                  int                    `db:"inbox_id" json:"-"`
 | 
			
		||||
	Meta                     json.RawMessage        `db:"meta" json:"meta"`
 | 
			
		||||
	Attachments              attachment.Attachments `db:"attachments" json:"attachments"`
 | 
			
		||||
	ConversationUUID         string                 `db:"conversation_uuid" json:"-"`
 | 
			
		||||
	ConversationAppliedSLAID null.Int               `db:"conversation_applied_sla_id" json:"-"`
 | 
			
		||||
	From                     string                 `db:"from"  json:"-"`
 | 
			
		||||
	Subject                  string                 `db:"subject" json:"-"`
 | 
			
		||||
	Channel                  string                 `db:"channel" json:"-"`
 | 
			
		||||
	To                       pq.StringArray         `db:"to"  json:"-"`
 | 
			
		||||
	CC                       pq.StringArray         `db:"cc" json:"-"`
 | 
			
		||||
	BCC                      pq.StringArray         `db:"bcc" json:"-"`
 | 
			
		||||
	References               []string               `json:"-"`
 | 
			
		||||
	InReplyTo                string                 `json:"-"`
 | 
			
		||||
	Headers                  textproto.MIMEHeader   `json:"-"`
 | 
			
		||||
	AltContent               string                 `db:"-" json:"-"`
 | 
			
		||||
	Media                    []mmodels.Media        `db:"-" json:"-"`
 | 
			
		||||
	IsCSAT                   bool                   `db:"-" json:"-"`
 | 
			
		||||
	Total                    int                    `db:"total" json:"-"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// CensorCSATContent redacts the content of a CSAT message to prevent leaking the CSAT survey public link.
 | 
			
		||||
 
 | 
			
		||||
@@ -68,8 +68,7 @@ SELECT
 | 
			
		||||
    as_latest.first_response_deadline_at,
 | 
			
		||||
    as_latest.resolution_deadline_at,
 | 
			
		||||
    next_sla.deadline_at AS next_response_deadline_at,
 | 
			
		||||
    next_sla.status as next_response_sla_event_status,
 | 
			
		||||
    as_latest.status as sla_status
 | 
			
		||||
    next_sla.met_at as next_response_met_at
 | 
			
		||||
    FROM conversations
 | 
			
		||||
    JOIN users ON contact_id = users.id
 | 
			
		||||
    JOIN inboxes ON inbox_id = inboxes.id  
 | 
			
		||||
@@ -82,10 +81,10 @@ SELECT
 | 
			
		||||
        ORDER BY created_at DESC LIMIT 1
 | 
			
		||||
    ) as_latest ON true
 | 
			
		||||
    LEFT JOIN LATERAL (
 | 
			
		||||
        SELECT se.deadline_at, se.status
 | 
			
		||||
        SELECT se.deadline_at, se.met_at
 | 
			
		||||
        FROM sla_events se
 | 
			
		||||
        WHERE se.applied_sla_id = as_latest.id
 | 
			
		||||
        AND se.type = 'next_response' AND se.status in ('pending', 'breached')
 | 
			
		||||
        AND se.type = 'next_response'
 | 
			
		||||
        ORDER BY se.created_at DESC
 | 
			
		||||
        LIMIT 1
 | 
			
		||||
    ) next_sla ON true
 | 
			
		||||
@@ -139,8 +138,7 @@ SELECT
 | 
			
		||||
   as_latest.first_response_deadline_at,
 | 
			
		||||
   as_latest.resolution_deadline_at,
 | 
			
		||||
   next_sla.deadline_at AS next_response_deadline_at,
 | 
			
		||||
   next_sla.status as next_response_sla_event_status,
 | 
			
		||||
   as_latest.status as sla_status,
 | 
			
		||||
   next_sla.met_at as next_response_met_at,
 | 
			
		||||
   as_latest.id as applied_sla_id
 | 
			
		||||
FROM conversations c
 | 
			
		||||
JOIN users ct ON c.contact_id = ct.id
 | 
			
		||||
@@ -156,10 +154,10 @@ LEFT JOIN LATERAL (
 | 
			
		||||
    ORDER BY created_at DESC LIMIT 1
 | 
			
		||||
) as_latest ON true
 | 
			
		||||
LEFT JOIN LATERAL (
 | 
			
		||||
  SELECT se.deadline_at, se.status
 | 
			
		||||
  SELECT se.deadline_at, se.met_at
 | 
			
		||||
  FROM sla_events se
 | 
			
		||||
  WHERE se.applied_sla_id = as_latest.id
 | 
			
		||||
  AND se.type = 'next_response' AND se.status in ('pending', 'breached')
 | 
			
		||||
  AND se.type = 'next_response'
 | 
			
		||||
  ORDER BY se.created_at DESC
 | 
			
		||||
  LIMIT 1
 | 
			
		||||
) next_sla ON true
 | 
			
		||||
@@ -428,6 +426,7 @@ SELECT
 | 
			
		||||
    ARRAY(SELECT jsonb_array_elements_text(m.meta->'to')) AS to,
 | 
			
		||||
    c.inbox_id,
 | 
			
		||||
    c.uuid as conversation_uuid,
 | 
			
		||||
    c.applied_sla_id as conversation_applied_sla_id,
 | 
			
		||||
    c.subject
 | 
			
		||||
FROM conversation_messages m
 | 
			
		||||
INNER JOIN conversations c ON c.id = m.conversation_id
 | 
			
		||||
 
 | 
			
		||||
@@ -153,13 +153,15 @@ RETURNING id;
 | 
			
		||||
 | 
			
		||||
-- name: set-latest-sla-event-met-at
 | 
			
		||||
UPDATE sla_events
 | 
			
		||||
SET met_at = NOW()
 | 
			
		||||
SET met_at = NOW(),
 | 
			
		||||
status = CASE WHEN NOW() > deadline_at THEN 'breached'::sla_event_status ELSE 'met'::sla_event_status END
 | 
			
		||||
WHERE id = (
 | 
			
		||||
  SELECT id FROM sla_events
 | 
			
		||||
  WHERE applied_sla_id = $1 AND type = $2 AND met_at IS NULL
 | 
			
		||||
  ORDER BY created_at DESC
 | 
			
		||||
  LIMIT 1
 | 
			
		||||
)
 | 
			
		||||
RETURNING met_at;
 | 
			
		||||
 | 
			
		||||
-- name: mark-sla-event-as-breached
 | 
			
		||||
UPDATE sla_events
 | 
			
		||||
 
 | 
			
		||||
@@ -187,7 +187,7 @@ func (m *Manager) Delete(id int) error {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// GetDeadlines returns the deadline for a given start time, sla policy and assigned team.
 | 
			
		||||
func (m *Manager) GetDeadlines(startTime time.Time, slaPolicyID, assignedTeamID int, skipNextResponse bool) (Deadlines, error) {
 | 
			
		||||
func (m *Manager) GetDeadlines(startTime time.Time, slaPolicyID, assignedTeamID int) (Deadlines, error) {
 | 
			
		||||
	var deadlines Deadlines
 | 
			
		||||
 | 
			
		||||
	businessHrs, timezone, err := m.getBusinessHoursAndTimezone(assignedTeamID)
 | 
			
		||||
@@ -224,10 +224,8 @@ func (m *Manager) GetDeadlines(startTime time.Time, slaPolicyID, assignedTeamID
 | 
			
		||||
	if deadlines.Resolution, err = calculateDeadline(sla.ResolutionTime); err != nil {
 | 
			
		||||
		return deadlines, err
 | 
			
		||||
	}
 | 
			
		||||
	if !skipNextResponse {
 | 
			
		||||
		if deadlines.NextResponse, err = calculateDeadline(sla.NextResponseTime); err != nil {
 | 
			
		||||
			return deadlines, err
 | 
			
		||||
		}
 | 
			
		||||
	if deadlines.NextResponse, err = calculateDeadline(sla.NextResponseTime); err != nil {
 | 
			
		||||
		return deadlines, err
 | 
			
		||||
	}
 | 
			
		||||
	return deadlines, nil
 | 
			
		||||
}
 | 
			
		||||
@@ -237,10 +235,12 @@ func (m *Manager) ApplySLA(startTime time.Time, conversationID, assignedTeamID,
 | 
			
		||||
	var sla models.SLAPolicy
 | 
			
		||||
 | 
			
		||||
	// Get deadlines for the SLA policy and assigned team.
 | 
			
		||||
	deadlines, err := m.GetDeadlines(startTime, slaPolicyID, assignedTeamID, true)
 | 
			
		||||
	deadlines, err := m.GetDeadlines(startTime, slaPolicyID, assignedTeamID)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return sla, err
 | 
			
		||||
	}
 | 
			
		||||
	// Next response is not set at this point, next response are stored in SLA events as there can be multiple entries for next response.
 | 
			
		||||
	deadlines.NextResponse = time.Time{}
 | 
			
		||||
 | 
			
		||||
	// Insert applied SLA entry.
 | 
			
		||||
	var appliedSLAID int
 | 
			
		||||
@@ -266,63 +266,68 @@ func (m *Manager) ApplySLA(startTime time.Time, conversationID, assignedTeamID,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// CreateNextResponseSLAEvent creates a next response SLA event for a conversation.
 | 
			
		||||
func (m *Manager) CreateNextResponseSLAEvent(conversationID, appliedSLAID, slaPolicyID, assignedTeamID int) error {
 | 
			
		||||
func (m *Manager) CreateNextResponseSLAEvent(conversationID, appliedSLAID, slaPolicyID, assignedTeamID int) (time.Time, error) {
 | 
			
		||||
	var slaPolicy models.SLAPolicy
 | 
			
		||||
	if err := m.q.GetSLA.Get(&slaPolicy, slaPolicyID); err != nil {
 | 
			
		||||
		if err == sql.ErrNoRows {
 | 
			
		||||
			return fmt.Errorf("SLA policy not found: %d", slaPolicyID)
 | 
			
		||||
			return time.Time{}, fmt.Errorf("SLA policy not found: %d", slaPolicyID)
 | 
			
		||||
		}
 | 
			
		||||
		m.lo.Error("error fetching SLA policy", "error", err)
 | 
			
		||||
		return fmt.Errorf("fetching SLA policy: %w", err)
 | 
			
		||||
		return time.Time{}, fmt.Errorf("fetching SLA policy: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if slaPolicy.NextResponseTime == "" {
 | 
			
		||||
		m.lo.Info("no next response time set for SLA policy, skipping event creation", "conversation_id", conversationID, "policy_id", slaPolicyID)
 | 
			
		||||
		return nil
 | 
			
		||||
		return time.Time{}, fmt.Errorf("no next response time set for SLA policy: %d", slaPolicyID)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Calculate the deadline for the next response SLA event.
 | 
			
		||||
	deadlines, err := m.GetDeadlines(time.Now(), slaPolicy.ID, assignedTeamID, false)
 | 
			
		||||
	deadlines, err := m.GetDeadlines(time.Now(), slaPolicy.ID, assignedTeamID)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		m.lo.Error("error calculating deadlines for next response SLA event", "error", err)
 | 
			
		||||
		return fmt.Errorf("calculating deadlines for next response SLA event: %w", err)
 | 
			
		||||
		return time.Time{}, fmt.Errorf("calculating deadlines for next response SLA event: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if deadlines.NextResponse.IsZero() {
 | 
			
		||||
		m.lo.Info("next response deadline is zero, skipping event creation", "conversation_id", conversationID, "policy_id", slaPolicyID)
 | 
			
		||||
		return nil
 | 
			
		||||
		return time.Time{}, fmt.Errorf("next response deadline is zero for conversation: %d and policy: %d", conversationID, slaPolicyID)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var slaEventID int
 | 
			
		||||
	if err := m.q.InsertNextResponseSLAEvent.QueryRow(appliedSLAID, slaPolicyID, deadlines.NextResponse).Scan(&slaEventID); err != nil {
 | 
			
		||||
		if err == sql.ErrNoRows {
 | 
			
		||||
			m.lo.Debug("sla event for next response already exists, skipping creation", "conversation_id", conversationID, "policy_id", slaPolicyID)
 | 
			
		||||
			return nil
 | 
			
		||||
			m.lo.Debug("unmet sla event for next response already exists, skipping creation", "conversation_id", conversationID, "policy_id", slaPolicyID)
 | 
			
		||||
			return time.Time{}, fmt.Errorf("unmet next response SLA event already exists for conversation: %d and policy: %d", conversationID, slaPolicyID)
 | 
			
		||||
		}
 | 
			
		||||
		m.lo.Error("error inserting SLA event", "error", err)
 | 
			
		||||
		return fmt.Errorf("inserting SLA event: %w", err)
 | 
			
		||||
		return time.Time{}, fmt.Errorf("inserting SLA event: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Update next SLA deadline in the conversation.
 | 
			
		||||
	// Update next SLA deadline (sla target) in the conversation.
 | 
			
		||||
	if _, err := m.q.UpdateConversationNextSLADeadline.Exec(conversationID, deadlines.NextResponse); err != nil {
 | 
			
		||||
		m.lo.Error("error updating conversation next SLA deadline", "error", err)
 | 
			
		||||
		return fmt.Errorf("updating conversation next SLA deadline: %w", err)
 | 
			
		||||
		return time.Time{}, fmt.Errorf("updating conversation next SLA deadline: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Create notification schedule for the next response SLA event.
 | 
			
		||||
	deadlines.FirstResponse = time.Time{}
 | 
			
		||||
	deadlines.Resolution = time.Time{}
 | 
			
		||||
	m.createNotificationSchedule(slaPolicy.Notifications, appliedSLAID, null.IntFrom(slaEventID), deadlines, Breaches{})
 | 
			
		||||
	return nil
 | 
			
		||||
	return deadlines.NextResponse, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// SetLatestSLAEventMetAt marks the latest SLA event as met for a given applied SLA.
 | 
			
		||||
func (m *Manager) SetLatestSLAEventMetAt(appliedSLAID int, metric string) error {
 | 
			
		||||
	if _, err := m.q.SetLatestSLAEventMetAt.Exec(appliedSLAID, metric); err != nil {
 | 
			
		||||
func (m *Manager) SetLatestSLAEventMetAt(appliedSLAID int, metric string) (time.Time, error) {
 | 
			
		||||
	var metAt time.Time
 | 
			
		||||
	if err := m.q.SetLatestSLAEventMetAt.QueryRow(appliedSLAID, metric).Scan(&metAt); err != nil {
 | 
			
		||||
		if err == sql.ErrNoRows {
 | 
			
		||||
			m.lo.Warn("no SLA event found for applied SLA ID and metric to update met at", "applied_sla_id", appliedSLAID, "metric", metric)
 | 
			
		||||
			return metAt, fmt.Errorf("no SLA event found for applied SLA ID: %d and metric: %s to update met at", appliedSLAID, metric)
 | 
			
		||||
		}
 | 
			
		||||
		m.lo.Error("error marking SLA event as met", "error", err)
 | 
			
		||||
		return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.sla}"), nil)
 | 
			
		||||
		return metAt, fmt.Errorf("marking SLA event as met: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
	return nil
 | 
			
		||||
	return metAt, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// evaluatePendingSLAEvents fetches pending SLA events and marks them as breached if the deadline has passed.
 | 
			
		||||
@@ -437,7 +442,9 @@ func (m *Manager) runSLAEventEvaluation(ctx context.Context, interval time.Durat
 | 
			
		||||
 | 
			
		||||
// SendNotifications picks scheduled SLA notifications from the database and sends them to agents as emails.
 | 
			
		||||
func (m *Manager) SendNotifications(ctx context.Context) error {
 | 
			
		||||
	time.Sleep(10 * time.Second)
 | 
			
		||||
	ticker := time.NewTicker(20 * time.Second)
 | 
			
		||||
	defer ticker.Stop()
 | 
			
		||||
 | 
			
		||||
	for {
 | 
			
		||||
		select {
 | 
			
		||||
		case <-ctx.Done():
 | 
			
		||||
@@ -449,26 +456,19 @@ func (m *Manager) SendNotifications(ctx context.Context) error {
 | 
			
		||||
					return err
 | 
			
		||||
				}
 | 
			
		||||
				m.lo.Error("error fetching scheduled SLA notifications", "error", err)
 | 
			
		||||
			} else {
 | 
			
		||||
			} else if len(notifications) > 0 {
 | 
			
		||||
				m.lo.Debug("found scheduled SLA notifications", "count", len(notifications))
 | 
			
		||||
				for _, notification := range notifications {
 | 
			
		||||
					// Exit early if context is done.
 | 
			
		||||
					select {
 | 
			
		||||
					case <-ctx.Done():
 | 
			
		||||
					if ctx.Err() != nil {
 | 
			
		||||
						return ctx.Err()
 | 
			
		||||
					default:
 | 
			
		||||
						if err := m.SendNotification(notification); err != nil {
 | 
			
		||||
							m.lo.Error("error sending notification", "error", err)
 | 
			
		||||
						}
 | 
			
		||||
					}
 | 
			
		||||
					if err := m.SendNotification(notification); err != nil {
 | 
			
		||||
						m.lo.Error("error sending notification", "error", err)
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
				if len(notifications) > 0 {
 | 
			
		||||
					m.lo.Debug("sent SLA notifications", "count", len(notifications))
 | 
			
		||||
				}
 | 
			
		||||
				m.lo.Debug("sent SLA notifications", "count", len(notifications))
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			// Sleep for short duration to avoid hammering the database.
 | 
			
		||||
			time.Sleep(20 * time.Second)
 | 
			
		||||
			<-ticker.C
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
@@ -490,9 +490,9 @@ func (m *Manager) SendNotification(scheduledNotification models.ScheduledSLANoti
 | 
			
		||||
		return fmt.Errorf("fetching applied SLA for notification: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// If conversation is `Resolved` / `Closed`, mark the notification as processed and skip sending.
 | 
			
		||||
	// If conversation is `Resolved` / `Closed`, mark the notification as processed and return.
 | 
			
		||||
	if appliedSLA.ConversationStatus == cmodels.StatusResolved || appliedSLA.ConversationStatus == cmodels.StatusClosed {
 | 
			
		||||
		m.lo.Info("skipping notification as conversation is resolved/closed", "conversation_id", appliedSLA.ConversationID)
 | 
			
		||||
		m.lo.Info("marking sla notification as processed as the conversation is resolved/closed", "status", appliedSLA.ConversationStatus, "scheduled_notification_id", scheduledNotification.ID)
 | 
			
		||||
		if _, err := m.q.MarkNotificationProcessed.Exec(scheduledNotification.ID); err != nil {
 | 
			
		||||
			m.lo.Error("error marking notification as processed", "error", err)
 | 
			
		||||
		}
 | 
			
		||||
@@ -501,7 +501,7 @@ func (m *Manager) SendNotification(scheduledNotification models.ScheduledSLANoti
 | 
			
		||||
 | 
			
		||||
	// Send to all recipients (agents).
 | 
			
		||||
	for _, recipientS := range scheduledNotification.Recipients {
 | 
			
		||||
		// Check if SLA is already met, if met for the metric, skip the notification and mark the notification as processed.
 | 
			
		||||
		// Check if SLA is already met, if met mark notification as processed and return.
 | 
			
		||||
		switch scheduledNotification.Metric {
 | 
			
		||||
		case MetricFirstResponse:
 | 
			
		||||
			if appliedSLA.FirstResponseMetAt.Valid {
 | 
			
		||||
@@ -545,6 +545,7 @@ func (m *Manager) SendNotification(scheduledNotification models.ScheduledSLANoti
 | 
			
		||||
			continue
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Recipient not found?
 | 
			
		||||
		if recipientID == 0 {
 | 
			
		||||
			if _, err := m.q.MarkNotificationProcessed.Exec(scheduledNotification.ID); err != nil {
 | 
			
		||||
				m.lo.Error("error marking notification as processed", "error", err)
 | 
			
		||||
@@ -722,7 +723,7 @@ func (m *Manager) createNotificationSchedule(notifications models.SlaNotificatio
 | 
			
		||||
	scheduleNotification := func(sendAt time.Time, metric, notifType string, recipients []string) {
 | 
			
		||||
		// Make sure the sendAt time is in not too far in the past.
 | 
			
		||||
		if sendAt.Before(time.Now().Add(-5 * time.Minute)) {
 | 
			
		||||
			m.lo.Debug("skipping scheduling notification as it is in the past", "send_at", sendAt)
 | 
			
		||||
			m.lo.Debug("skipping scheduling notification as it is in the past", "send_at", sendAt, "applied_sla_id", appliedSLAID, "metric", metric, "type", notifType)
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
		if _, err := m.q.InsertScheduledSLANotification.Exec(appliedSLAID, slaEventID, metric, notifType, pq.Array(recipients), sendAt); err != nil {
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user