Fixes for next response time sla metric

This commit is contained in:
Abhinav Raut
2025-05-24 23:05:24 +05:30
parent cf20142e40
commit 7e8c9962c3
8 changed files with 182 additions and 143 deletions

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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 {