diff --git a/cmd/main.go b/cmd/main.go
index 7779b01..80db36c 100644
--- a/cmd/main.go
+++ b/cmd/main.go
@@ -195,7 +195,7 @@ func main() {
go conversation.Run(ctx, messageIncomingQWorkers, messageOutgoingQWorkers, messageOutgoingScanInterval)
go conversation.RunUnsnoozer(ctx, unsnoozeInterval)
go notifier.Run(ctx)
- go sla.Run(ctx, slaEvaluationInterval)
+ go sla.Start(ctx, slaEvaluationInterval)
go sla.SendNotifications(ctx)
go media.DeleteUnlinkedMedia(ctx)
go user.MonitorAgentAvailability(ctx)
diff --git a/cmd/messages.go b/cmd/messages.go
index 5f38c35..78ded39 100644
--- a/cmd/messages.go
+++ b/cmd/messages.go
@@ -7,6 +7,7 @@ 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"
)
@@ -172,6 +173,9 @@ 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)
diff --git a/cmd/sla.go b/cmd/sla.go
index c58d264..d838be5 100644
--- a/cmd/sla.go
+++ b/cmd/sla.go
@@ -54,7 +54,7 @@ func handleCreateSLA(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
- if err := app.sla.Create(sla.Name, sla.Description, sla.FirstResponseTime, sla.ResolutionTime, sla.Notifications); err != nil {
+ if err := app.sla.Create(sla.Name, sla.Description, sla.FirstResponseTime, sla.ResolutionTime, sla.NextResponseTime, sla.Notifications); err != nil {
return sendErrorEnvelope(r, err)
}
@@ -81,11 +81,11 @@ func handleUpdateSLA(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
- if err := app.sla.Update(id, sla.Name, sla.Description, sla.FirstResponseTime, sla.ResolutionTime, sla.Notifications); err != nil {
+ if err := app.sla.Update(id, sla.Name, sla.Description, sla.FirstResponseTime, sla.ResolutionTime, sla.NextResponseTime, sla.Notifications); err != nil {
return sendErrorEnvelope(r, err)
}
- return r.SendEnvelope("SLA updated successfully.")
+ return r.SendEnvelope(true)
}
// handleDeleteSLA deletes the SLA with the given ID.
@@ -155,5 +155,13 @@ func validateSLA(app *App, sla *smodels.SLAPolicy) error {
return envelope.NewError(envelope.InputError, app.i18n.T("sla.firstResponseTimeAfterResolution"), nil)
}
+ nrt, err := time.ParseDuration(sla.NextResponseTime)
+ if err != nil {
+ return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`next_response_time`"), nil)
+ }
+ if nrt.Seconds() < 1 {
+ return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`next_response_time`"), nil)
+ }
+
return nil
}
diff --git a/frontend/src/features/admin/sla/SLAForm.vue b/frontend/src/features/admin/sla/SLAForm.vue
index cc10309..7003260 100644
--- a/frontend/src/features/admin/sla/SLAForm.vue
+++ b/frontend/src/features/admin/sla/SLAForm.vue
@@ -44,6 +44,19 @@
+
{{ notification.type === 'warning' ? 'Pre-breach alert' : 'Post-breach action' }}
@@ -149,7 +165,11 @@
{{ $t('form.field.subject') }}
@@ -66,7 +63,19 @@
{{ $t('form.field.lastReplyAt') }}
+{{ $t('form.field.lastReplyAt') }}
+{{ format(conversation.last_reply_at, 'PPpp') }} diff --git a/i18n/en.json b/i18n/en.json index 35f0503..94e2483 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -385,6 +385,8 @@ "admin.sla.firstResponseTime.description": "Duration in hours or minutes. Example: 1h, 30m, 1h30m", "admin.sla.resolutionTime": "Resolution Time", "admin.sla.resolutionTime.description": "Duration in hours or minutes. Example: 1h, 30m, 1h30m", + "admin.sla.nextResponseTime": "Next Response Time", + "admin.sla.nextResponseTime.description": "Duration in hours or minutes. Example: 1h, 30m, 1h30m", "admin.sla.alertConfiguration": "Alert Configuration", "admin.sla.alertConfiguration.description": "Set up notification triggers and recipients", "admin.sla.addBreachAlert": "Add Breach Alert", diff --git a/internal/conversation/conversation.go b/internal/conversation/conversation.go index 6804914..1d023d8 100644 --- a/internal/conversation/conversation.go +++ b/internal/conversation/conversation.go @@ -82,6 +82,7 @@ type Manager struct { type slaStore interface { ApplySLA(startTime time.Time, conversationID, assignedTeamID, slaID int) (slaModels.SLAPolicy, error) + CreateNextResponseSLAEvent(conversationID, appliedSLAID, slaPolicyID, assignedTeamID int) error } type statusStore interface { diff --git a/internal/conversation/message.go b/internal/conversation/message.go index 2f86eda..a04cd40 100644 --- a/internal/conversation/message.go +++ b/internal/conversation/message.go @@ -392,9 +392,7 @@ func (m *Manager) InsertMessage(message *models.Message) error { } // Add this user as a participant. - if err := m.addConversationParticipant(message.SenderID, message.ConversationUUID); err != nil { - return err - } + m.addConversationParticipant(message.SenderID, message.ConversationUUID) // Hide CSAT message content as it contains a public link to the survey. lastMessage := message.TextContent @@ -577,6 +575,20 @@ 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. + conversation, err := m.GetConversation(in.Message.ConversationID, "") + if err != nil { + m.lo.Error("error fetching conversation", "conversation_id", in.Message.ConversationID, "error", err) + } + if conversation.SLAPolicyID.Int == 0 { + 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 { + 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) + } return nil } diff --git a/internal/conversation/models/models.go b/internal/conversation/models/models.go index 6be3305..383450d 100644 --- a/internal/conversation/models/models.go +++ b/internal/conversation/models/models.go @@ -53,48 +53,51 @@ 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"` - 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"` - 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"` + 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:"-"` } type ConversationParticipant struct { diff --git a/internal/conversation/queries.sql b/internal/conversation/queries.sql index c974ef3..0127dbe 100644 --- a/internal/conversation/queries.sql +++ b/internal/conversation/queries.sql @@ -67,6 +67,8 @@ SELECT conversation_priorities.name as priority, 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 FROM conversations JOIN users ON contact_id = users.id @@ -74,11 +76,19 @@ SELECT LEFT JOIN conversation_statuses ON status_id = conversation_statuses.id LEFT JOIN conversation_priorities ON priority_id = conversation_priorities.id LEFT JOIN LATERAL ( - SELECT first_response_deadline_at, resolution_deadline_at, status + SELECT id, first_response_deadline_at, resolution_deadline_at, status FROM applied_slas WHERE conversation_id = conversations.id ORDER BY created_at DESC LIMIT 1 ) as_latest ON true + LEFT JOIN LATERAL ( + SELECT se.deadline_at, se.status + FROM sla_events se + WHERE se.applied_sla_id = as_latest.id + AND se.type = 'next_response' AND se.status in ('pending', 'breached') + ORDER BY se.created_at DESC + LIMIT 1 + ) next_sla ON true WHERE 1=1 %s -- name: get-conversation @@ -128,7 +138,10 @@ SELECT ct.custom_attributes as "contact.custom_attributes", as_latest.first_response_deadline_at, as_latest.resolution_deadline_at, - as_latest.status as sla_status + next_sla.deadline_at AS next_response_deadline_at, + next_sla.status as next_response_sla_event_status, + as_latest.status as sla_status, + as_latest.id as applied_sla_id FROM conversations c JOIN users ct ON c.contact_id = ct.id JOIN inboxes inb ON c.inbox_id = inb.id @@ -137,11 +150,19 @@ LEFT JOIN teams at ON at.id = c.assigned_team_id LEFT JOIN conversation_statuses s ON c.status_id = s.id LEFT JOIN conversation_priorities p ON c.priority_id = p.id LEFT JOIN LATERAL ( - SELECT first_response_deadline_at, resolution_deadline_at, status - FROM applied_slas + SELECT id, first_response_deadline_at, resolution_deadline_at, status + FROM applied_slas WHERE conversation_id = c.id ORDER BY created_at DESC LIMIT 1 ) as_latest ON true +LEFT JOIN LATERAL ( + SELECT se.deadline_at, se.status + FROM sla_events se + WHERE se.applied_sla_id = as_latest.id + AND se.type = 'next_response' AND se.status in ('pending', 'breached') + ORDER BY se.created_at DESC + LIMIT 1 +) next_sla ON true WHERE ($1 > 0 AND c.id = $1) OR diff --git a/internal/macro/macro.go b/internal/macro/macro.go index 98d1763..035944f 100644 --- a/internal/macro/macro.go +++ b/internal/macro/macro.go @@ -28,12 +28,12 @@ type Manager struct { // Predefined queries. type queries struct { - Get *sqlx.Stmt `query:"get"` - GetAll *sqlx.Stmt `query:"get-all"` - Create *sqlx.Stmt `query:"create"` - Update *sqlx.Stmt `query:"update"` - Delete *sqlx.Stmt `query:"delete"` - IncUsageCount *sqlx.Stmt `query:"increment-usage-count"` + Get *sqlx.Stmt `query:"get"` + GetAll *sqlx.Stmt `query:"get-all"` + Create *sqlx.Stmt `query:"create"` + Update *sqlx.Stmt `query:"update"` + Delete *sqlx.Stmt `query:"delete"` + IncrUsageCount *sqlx.Stmt `query:"increment-usage-count"` } // Opts contains the dependencies for the macro manager. @@ -115,7 +115,7 @@ func (m *Manager) Delete(id int) error { // IncrementUsageCount increments the usage count of a macro. func (m *Manager) IncrementUsageCount(id int) error { - if _, err := m.q.IncUsageCount.Exec(id); err != nil { + if _, err := m.q.IncrUsageCount.Exec(id); err != nil { m.lo.Error("error incrementing usage count", "error", err) return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorUpdating", "name", "macro usage count"), nil) } diff --git a/internal/migrations/v0.6.0.go b/internal/migrations/v0.6.0.go index e1b26db..e7ebf9b 100644 --- a/internal/migrations/v0.6.0.go +++ b/internal/migrations/v0.6.0.go @@ -207,5 +207,93 @@ func V0_6_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error { return err } + // Add column `next_response_time` to sla_policies table if it doesn't exist + _, err = db.Exec(` + ALTER TABLE sla_policies ADD COLUMN IF NOT EXISTS next_response_time TEXT NULL; + `) + if err != nil { + return err + } + + // Add `next_response` value to type if it doesn't exist. + _, err = db.Exec(` + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 FROM pg_type WHERE typname = 'sla_metric' + ) AND NOT EXISTS ( + SELECT 1 FROM pg_enum e + JOIN pg_type t ON t.oid = e.enumtypid + WHERE t.typname = 'sla_metric' + AND e.enumlabel = 'next_response' + ) THEN + ALTER TYPE sla_metric ADD VALUE 'next_response'; + END IF; + END + $$; + `) + + // Create sla_event_status enum type if it doesn't exist + _, err = db.Exec(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_type WHERE typname = 'sla_event_status' + ) THEN + CREATE TYPE sla_event_status AS ENUM ('pending', 'breached', 'met'); + END IF; + END + $$; + `) + if err != nil { + return err + } + + // Add applied_sla_id column to conversations table if it doesn't exist + _, err = db.Exec(` + ALTER TABLE conversations ADD COLUMN IF NOT EXISTS applied_sla_id BIGINT REFERENCES applied_slas(id) ON DELETE SET NULL ON UPDATE CASCADE; + `) + if err != nil { + return err + } + + // Create sla_events table if it does not exist + _, err = db.Exec(` + CREATE TABLE IF NOT EXISTS sla_events ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + status sla_event_status DEFAULT 'pending' NOT NULL, + applied_sla_id BIGINT REFERENCES applied_slas(id) ON DELETE CASCADE ON UPDATE CASCADE NOT NULL, + sla_policy_id INT REFERENCES sla_policies(id) ON DELETE CASCADE ON UPDATE CASCADE NOT NULL, + type sla_metric NOT NULL, + deadline_at TIMESTAMPTZ NOT NULL, + met_at TIMESTAMPTZ, + breached_at TIMESTAMPTZ + ); + CREATE INDEX IF NOT EXISTS index_sla_events_on_applied_sla_id ON sla_events(applied_sla_id); + CREATE INDEX IF NOT EXISTS index_sla_events_on_status ON sla_events(status); + `) + if err != nil { + return err + } + + // Add sla_event_id column to scheduled_sla_notifications if it doesn't exist + _, err = db.Exec(` + ALTER TABLE scheduled_sla_notifications + ADD COLUMN IF NOT EXISTS sla_event_id BIGINT REFERENCES sla_events(id) ON DELETE CASCADE; + `) + if err != nil { + return err + } + + // Create index on team_members(user_id) if it doesn't exist + _, err = db.Exec(` + CREATE INDEX IF NOT EXISTS index_team_members_on_user_id ON team_members (user_id); + `) + if err != nil { + return err + } + return nil } diff --git a/internal/sla/models/models.go b/internal/sla/models/models.go index 97171d3..d0d7bed 100644 --- a/internal/sla/models/models.go +++ b/internal/sla/models/models.go @@ -19,6 +19,7 @@ type SLAPolicy struct { Description string `db:"description" json:"description,omitempty"` FirstResponseTime string `db:"first_response_time" json:"first_response_time,omitempty"` EveryResponseTime string `db:"every_response_time" json:"every_response_time,omitempty"` + NextResponseTime string `db:"next_response_time" json:"next_response_time,omitempty"` ResolutionTime string `db:"resolution_time" json:"resolution_time,omitempty"` Notifications SlaNotifications `db:"notifications" json:"notifications,omitempty"` } @@ -58,6 +59,7 @@ type ScheduledSLANotification struct { ID int `db:"id" json:"id"` CreatedAt time.Time `db:"created_at" json:"created_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + SlaEventID null.Int `db:"sla_event_id" json:"sla_event_id"` AppliedSLAID int `db:"applied_sla_id" json:"applied_sla_id"` Metric string `db:"metric" json:"metric"` NotificationType string `db:"notification_type" json:"notification_type"` @@ -87,4 +89,17 @@ type AppliedSLA struct { ConversationReferenceNumber string `db:"conversation_reference_number"` ConversationSubject string `db:"conversation_subject"` ConversationAssignedUserID null.Int `db:"conversation_assigned_user_id"` + ConversationStatus string `db:"conversation_status"` +} + +type SLAEvent struct { + ID int `db:"id"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + AppliedSLAID int `db:"applied_sla_id"` + SlaPolicyID int `db:"sla_policy_id"` + Type string `db:"type"` + DeadlineAt time.Time `db:"deadline_at"` + MetAt null.Time `db:"met_at"` + BreachedAt null.Time `db:"breached_at"` } diff --git a/internal/sla/queries.sql b/internal/sla/queries.sql index 815c96e..c84e8a9 100644 --- a/internal/sla/queries.sql +++ b/internal/sla/queries.sql @@ -1,5 +1,5 @@ -- name: get-sla-policy -SELECT id, name, description, first_response_time, resolution_time, notifications, created_at, updated_at FROM sla_policies WHERE id = $1; +SELECT id, name, description, first_response_time, resolution_time, next_response_time, notifications, created_at, updated_at FROM sla_policies WHERE id = $1; -- name: get-all-sla-policies SELECT id, name, created_at, updated_at FROM sla_policies ORDER BY updated_at DESC; @@ -10,8 +10,9 @@ INSERT INTO sla_policies ( description, first_response_time, resolution_time, + next_response_time, notifications -) VALUES ($1, $2, $3, $4, $5); +) VALUES ($1, $2, $3, $4, $5, $6); -- name: update-sla-policy UPDATE sla_policies SET @@ -19,7 +20,8 @@ UPDATE sla_policies SET description = $3, first_response_time = $4, resolution_time = $5, - notifications = $6, + next_response_time = $6, + notifications = $7, updated_at = NOW() WHERE id = $1; @@ -36,9 +38,12 @@ WITH new_sla AS ( ) VALUES ($1, $2, $3, $4) RETURNING conversation_id, id ) +-- update the conversation with the new SLA policy and applied SLA UPDATE conversations c -SET sla_policy_id = $2, - next_sla_deadline_at = LEAST($3, $4) +SET + sla_policy_id = $2, + next_sla_deadline_at = LEAST($3, $4), + applied_sla_id = ns.id FROM new_sla ns WHERE c.id = ns.conversation_id RETURNING ns.id; @@ -95,14 +100,15 @@ WHERE applied_slas.id = $1; -- name: insert-scheduled-sla-notification INSERT INTO scheduled_sla_notifications ( applied_sla_id, + sla_event_id, metric, notification_type, recipients, send_at -) VALUES ($1, $2, $3, $4, $5); +) VALUES ($1, $2, $3, $4, $5, $6); -- name: get-scheduled-sla-notifications -SELECT id, created_at, updated_at, applied_sla_id, metric, notification_type, recipients, send_at, processed_at +SELECT id, created_at, updated_at, applied_sla_id, sla_event_id, metric, notification_type, recipients, send_at, processed_at FROM scheduled_sla_notifications WHERE send_at <= NOW() AND processed_at IS NULL; @@ -124,12 +130,59 @@ SELECT a.id, c.uuid as conversation_uuid, c.reference_number as conversation_reference_number, c.subject as conversation_subject, - c.assigned_user_id as conversation_assigned_user_id -FROM applied_slas a inner join conversations c on a.conversation_id = c.id + c.assigned_user_id as conversation_assigned_user_id, + s.name as conversation_status +FROM applied_slas a INNER JOIN conversations c on a.conversation_id = c.id +LEFT JOIN conversation_statuses s ON c.status_id = s.id WHERE a.id = $1; -- name: mark-notification-processed UPDATE scheduled_sla_notifications SET processed_at = NOW(), updated_at = NOW() -WHERE id = $1; \ No newline at end of file +WHERE id = $1; + +-- name: insert-next-response-sla-event +INSERT INTO sla_events (applied_sla_id, sla_policy_id, type, deadline_at) +SELECT $1, $2, 'next_response', $3 +WHERE NOT EXISTS ( + SELECT 1 FROM sla_events + WHERE applied_sla_id = $1 AND type = 'next_response' AND met_at IS NULL +) +RETURNING id; + +-- name: set-latest-sla-event-met-at +UPDATE sla_events +SET met_at = NOW() +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 +) + +-- name: mark-sla-event-as-breached +UPDATE sla_events +SET breached_at = NOW(), +status = 'breached' +WHERE id = $1; + +-- name: mark-sla-event-as-met +UPDATE sla_events +SET status = 'met' +WHERE id = $1; + +-- name: get-sla-event +SELECT id, created_at, updated_at, applied_sla_id, sla_policy_id, type, deadline_at, met_at, breached_at +FROM sla_events +WHERE id = $1; + +-- name: update-conversation-next-sla-deadline +UPDATE conversations +SET next_sla_deadline_at = LEAST(next_sla_deadline_at, $2) +WHERE id = $1; + +-- name: get-pending-sla-events +SELECT id +FROM sla_events +WHERE status = 'pending' and deadline_at IS NOT NULL; \ No newline at end of file diff --git a/internal/sla/sla.go b/internal/sla/sla.go index 8cc622c..0aff70a 100644 --- a/internal/sla/sla.go +++ b/internal/sla/sla.go @@ -12,6 +12,7 @@ import ( businesshours "github.com/abhinavxd/libredesk/internal/business_hours" bmodels "github.com/abhinavxd/libredesk/internal/business_hours/models" + cmodels "github.com/abhinavxd/libredesk/internal/conversation/models" "github.com/abhinavxd/libredesk/internal/dbutil" "github.com/abhinavxd/libredesk/internal/envelope" notifier "github.com/abhinavxd/libredesk/internal/notification" @@ -35,7 +36,8 @@ var ( const ( MetricFirstResponse = "first_response" - MetricsResolution = "resolution" + MetricResolution = "resolution" + MetricNextResponse = "next_response" NotificationTypeWarning = "warning" NotificationTypeBreach = "breach" @@ -43,7 +45,8 @@ const ( var metricLabels = map[string]string{ MetricFirstResponse: "First Response", - MetricsResolution: "Resolution", + MetricResolution: "Resolution", + MetricNextResponse: "Next Response", } // Manager manages SLA policies and calculations. @@ -72,12 +75,14 @@ type Opts struct { type Deadlines struct { FirstResponse time.Time Resolution time.Time + NextResponse time.Time } // Breaches holds the breach timestamps for an SLA policy. type Breaches struct { FirstResponse time.Time Resolution time.Time + NextResponse time.Time } type teamStore interface { @@ -98,21 +103,28 @@ type businessHrsStore interface { // queries hold prepared SQL queries. type queries struct { - GetSLA *sqlx.Stmt `query:"get-sla-policy"` - GetAllSLA *sqlx.Stmt `query:"get-all-sla-policies"` - GetAppliedSLA *sqlx.Stmt `query:"get-applied-sla"` - GetScheduledSLANotifications *sqlx.Stmt `query:"get-scheduled-sla-notifications"` - InsertScheduledSLANotification *sqlx.Stmt `query:"insert-scheduled-sla-notification"` - InsertSLA *sqlx.Stmt `query:"insert-sla-policy"` - DeleteSLA *sqlx.Stmt `query:"delete-sla-policy"` - UpdateSLA *sqlx.Stmt `query:"update-sla-policy"` - ApplySLA *sqlx.Stmt `query:"apply-sla"` - GetPendingSLAs *sqlx.Stmt `query:"get-pending-slas"` - UpdateBreach *sqlx.Stmt `query:"update-breach"` - UpdateMet *sqlx.Stmt `query:"update-met"` - SetNextSLADeadline *sqlx.Stmt `query:"set-next-sla-deadline"` - UpdateSLAStatus *sqlx.Stmt `query:"update-sla-status"` - MarkNotificationProcessed *sqlx.Stmt `query:"mark-notification-processed"` + GetSLA *sqlx.Stmt `query:"get-sla-policy"` + GetAllSLA *sqlx.Stmt `query:"get-all-sla-policies"` + GetAppliedSLA *sqlx.Stmt `query:"get-applied-sla"` + GetSLAEvent *sqlx.Stmt `query:"get-sla-event"` + GetScheduledSLANotifications *sqlx.Stmt `query:"get-scheduled-sla-notifications"` + InsertScheduledSLANotification *sqlx.Stmt `query:"insert-scheduled-sla-notification"` + InsertSLA *sqlx.Stmt `query:"insert-sla-policy"` + InsertNextResponseSLAEvent *sqlx.Stmt `query:"insert-next-response-sla-event"` + DeleteSLA *sqlx.Stmt `query:"delete-sla-policy"` + UpdateSLA *sqlx.Stmt `query:"update-sla-policy"` + ApplySLA *sqlx.Stmt `query:"apply-sla"` + GetPendingSLAs *sqlx.Stmt `query:"get-pending-slas"` + UpdateBreach *sqlx.Stmt `query:"update-breach"` + UpdateMet *sqlx.Stmt `query:"update-met"` + UpdateConversationNextSLADeadline *sqlx.Stmt `query:"update-conversation-next-sla-deadline"` + SetNextSLADeadline *sqlx.Stmt `query:"set-next-sla-deadline"` + GetPendingSLAEvents *sqlx.Stmt `query:"get-pending-sla-events"` + UpdateSLAStatus *sqlx.Stmt `query:"update-sla-status"` + MarkNotificationProcessed *sqlx.Stmt `query:"mark-notification-processed"` + MarkSLAEventAsBreached *sqlx.Stmt `query:"mark-sla-event-as-breached"` + MarkSLAEventAsMet *sqlx.Stmt `query:"mark-sla-event-as-met"` + SetLatestSLAEventMetAt *sqlx.Stmt `query:"set-latest-sla-event-met-at"` } // New creates a new SLA manager. @@ -148,8 +160,8 @@ func (m *Manager) GetAll() ([]models.SLAPolicy, error) { } // Create creates a new SLA policy. -func (m *Manager) Create(name, description string, firstResponseTime, resolutionTime string, notifications models.SlaNotifications) error { - if _, err := m.q.InsertSLA.Exec(name, description, firstResponseTime, resolutionTime, notifications); err != nil { +func (m *Manager) Create(name, description string, firstResponseTime, resolutionTime, nextResponseTime string, notifications models.SlaNotifications) error { + if _, err := m.q.InsertSLA.Exec(name, description, firstResponseTime, resolutionTime, nextResponseTime, notifications); err != nil { m.lo.Error("error inserting SLA", "error", err) return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.sla}"), nil) } @@ -157,8 +169,8 @@ func (m *Manager) Create(name, description string, firstResponseTime, resolution } // Update updates a SLA policy. -func (m *Manager) Update(id int, name, description string, firstResponseTime, resolutionTime string, notifications models.SlaNotifications) error { - if _, err := m.q.UpdateSLA.Exec(id, name, description, firstResponseTime, resolutionTime, notifications); err != nil { +func (m *Manager) Update(id int, name, description string, firstResponseTime, resolutionTime, nextResponseTime string, notifications models.SlaNotifications) error { + if _, err := m.q.UpdateSLA.Exec(id, name, description, firstResponseTime, resolutionTime, nextResponseTime, notifications); err != nil { m.lo.Error("error updating SLA", "error", err) return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.sla}"), nil) } @@ -175,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) (Deadlines, error) { +func (m *Manager) GetDeadlines(startTime time.Time, slaPolicyID, assignedTeamID int, skipNextResponse bool) (Deadlines, error) { var deadlines Deadlines businessHrs, timezone, err := m.getBusinessHoursAndTimezone(assignedTeamID) @@ -197,7 +209,7 @@ func (m *Manager) GetDeadlines(startTime time.Time, slaPolicyID, assignedTeamID } dur, err := time.ParseDuration(durationStr) if err != nil { - return time.Time{}, fmt.Errorf("parsing SLA duration: %v", err) + return time.Time{}, fmt.Errorf("parsing SLA duration (%s): %v", durationStr, err) } deadline, err := m.CalculateDeadline(startTime, int(dur.Minutes()), businessHrs, timezone) if err != nil { @@ -212,6 +224,11 @@ 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 + } + } return deadlines, nil } @@ -220,7 +237,7 @@ 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) + deadlines, err := m.GetDeadlines(startTime, slaPolicyID, assignedTeamID, true) if err != nil { return sla, err } @@ -237,26 +254,156 @@ func (m *Manager) ApplySLA(startTime time.Time, conversationID, assignedTeamID, return sla, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorApplying", "name", "{globals.terms.sla}"), nil) } + // Schedule SLA notifications if any exist. SLA breaches have not occurred yet, as this is the first time the SLA is being applied. + // Therefore, only schedule notifications for the deadlines. sla, err = m.Get(slaPolicyID) if err != nil { return sla, err } - - // Schedule SLA notifications if there are any, SLA breaches did not happen yet as this is the first time SLA is applied. - // So, only schedule SLA breach warnings. - m.createNotificationSchedule(sla.Notifications, appliedSLAID, deadlines, Breaches{}) + m.createNotificationSchedule(sla.Notifications, appliedSLAID, null.Int{}, deadlines, Breaches{}) return sla, nil } -// Run starts the SLA evaluation loop and evaluates pending SLAs. -func (m *Manager) Run(ctx context.Context, evalInterval time.Duration) { - ticker := time.NewTicker(evalInterval) - m.wg.Add(1) - defer func() { - m.wg.Done() - ticker.Stop() - }() +// CreateNextResponseSLAEvent creates a next response SLA event for a conversation. +func (m *Manager) CreateNextResponseSLAEvent(conversationID, appliedSLAID, slaPolicyID, assignedTeamID int) 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) + } + m.lo.Error("error fetching SLA policy", "error", err) + return 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 + } + + // Calculate the deadline for the next response SLA event. + deadlines, err := m.GetDeadlines(time.Now(), slaPolicy.ID, assignedTeamID, false) + 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) + } + + if deadlines.NextResponse.IsZero() { + m.lo.Info("next response deadline is zero, skipping event creation", "conversation_id", conversationID, "policy_id", slaPolicyID) + return nil + } + + 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.Error("error inserting SLA event", "error", err) + return fmt.Errorf("inserting SLA event: %w", err) + } + + // Update next SLA deadline 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) + } + + // 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 +} + +// 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 { + 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 nil +} + +// evaluatePendingSLAEvents fetches pending SLA events and marks them as breached if the deadline has passed. +func (m *Manager) evaluatePendingSLAEvents(ctx context.Context) error { + var slaEvents []models.SLAEvent + if err := m.q.GetPendingSLAEvents.SelectContext(ctx, &slaEvents); err != nil { + m.lo.Error("error fetching pending SLA events", "error", err) + return fmt.Errorf("fetching pending SLA events: %w", err) + } + m.lo.Info("found SLA events that have breached", "count", len(slaEvents)) + + // Cache for SLA policies. + var slaPolicyCache = make(map[int]models.SLAPolicy) + for _, event := range slaEvents { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + if err := m.q.GetSLAEvent.GetContext(ctx, &event, event.ID); err != nil { + m.lo.Error("error fetching SLA event", "error", err) + continue + } + + if event.DeadlineAt.IsZero() { + m.lo.Warn("SLA event deadline is zero, skipping marking as breached", "sla_event_id", event.ID) + continue + } + + // Met at after the deadline or current time is after the deadline - mark event breached. + var hasBreached bool + if (event.MetAt.Valid && event.MetAt.Time.After(event.DeadlineAt)) || (time.Now().After(event.DeadlineAt) && !event.MetAt.Valid) { + hasBreached = true + if _, err := m.q.MarkSLAEventAsBreached.Exec(event.ID); err != nil { + m.lo.Error("error marking SLA event as breached", "error", err) + continue + } + } + + // Met at before the deadline - mark event met. + if event.MetAt.Valid && event.MetAt.Time.Before(event.DeadlineAt) { + if _, err := m.q.MarkSLAEventAsMet.Exec(event.ID); err != nil { + m.lo.Error("error marking SLA event as met", "error", err) + continue + } + } + + // Schedule a breach notification if the event is not met at all. + if !event.MetAt.Valid && hasBreached { + // Check if the SLA policy is already cached. + slaPolicy, ok := slaPolicyCache[event.SlaPolicyID] + if !ok { + var err error + slaPolicy, err = m.Get(event.SlaPolicyID) + if err != nil { + m.lo.Error("error fetching SLA policy", "error", err) + continue + } + slaPolicyCache[event.SlaPolicyID] = slaPolicy + } + m.createNotificationSchedule(slaPolicy.Notifications, event.AppliedSLAID, null.IntFrom(event.ID), Deadlines{}, Breaches{ + NextResponse: time.Now(), + }) + } + } + return nil +} + +// Start begins SLA and SLA event evaluation loops in separate goroutines. +func (m *Manager) Start(ctx context.Context, interval time.Duration) { + m.wg.Add(2) + go m.runSLAEvaluation(ctx, interval) + go m.runSLAEventEvaluation(ctx, interval) +} + +// runSLAEvaluation periodically evaluates pending SLAs. +func (m *Manager) runSLAEvaluation(ctx context.Context, interval time.Duration) { + defer m.wg.Done() + ticker := time.NewTicker(interval) + defer ticker.Stop() for { select { @@ -270,6 +417,24 @@ func (m *Manager) Run(ctx context.Context, evalInterval time.Duration) { } } +// runSLAEventEvaluation periodically evaluates pending SLA events. +func (m *Manager) runSLAEventEvaluation(ctx context.Context, interval time.Duration) { + defer m.wg.Done() + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := m.evaluatePendingSLAEvents(ctx); err != nil { + m.lo.Error("error marking SLA events as breached", "error", err) + } + } + } +} + // 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) @@ -303,34 +468,64 @@ func (m *Manager) SendNotifications(ctx context.Context) error { } // Sleep for short duration to avoid hammering the database. - time.Sleep(30 * time.Second) + time.Sleep(20 * time.Second) } } } // SendNotification sends a SLA notification to agents. func (m *Manager) SendNotification(scheduledNotification models.ScheduledSLANotification) error { - var appliedSLA models.AppliedSLA + var ( + appliedSLA models.AppliedSLA + slaEvent models.SLAEvent + ) + if scheduledNotification.SlaEventID.Int != 0 { + if err := m.q.GetSLAEvent.Get(&slaEvent, scheduledNotification.SlaEventID.Int); err != nil { + m.lo.Error("error fetching SLA event", "error", err) + return fmt.Errorf("fetching SLA event for notification: %w", err) + } + } if err := m.q.GetAppliedSLA.Get(&appliedSLA, scheduledNotification.AppliedSLAID); err != nil { m.lo.Error("error fetching applied SLA", "error", err) return fmt.Errorf("fetching applied SLA for notification: %w", err) } + // If conversation is `Resolved` / `Closed`, mark the notification as processed and skip sending. + if appliedSLA.ConversationStatus == cmodels.StatusResolved || appliedSLA.ConversationStatus == cmodels.StatusClosed { + m.lo.Info("skipping notification as conversation is resolved/closed", "conversation_id", appliedSLA.ConversationID) + if _, err := m.q.MarkNotificationProcessed.Exec(scheduledNotification.ID); err != nil { + m.lo.Error("error marking notification as processed", "error", err) + } + return nil + } + // 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. switch scheduledNotification.Metric { case MetricFirstResponse: if appliedSLA.FirstResponseMetAt.Valid { - m.lo.Debug("skipping notification as first response is already met", "applied_sla_id", appliedSLA.ID) + m.lo.Info("skipping notification as first response is already met", "applied_sla_id", appliedSLA.ID) if _, err := m.q.MarkNotificationProcessed.Exec(scheduledNotification.ID); err != nil { m.lo.Error("error marking notification as processed", "error", err) } continue } - case MetricsResolution: + case MetricResolution: if appliedSLA.ResolutionMetAt.Valid { - m.lo.Debug("skipping notification as resolution is already met", "applied_sla_id", appliedSLA.ID) + m.lo.Info("skipping notification as resolution is already met", "applied_sla_id", appliedSLA.ID) + if _, err := m.q.MarkNotificationProcessed.Exec(scheduledNotification.ID); err != nil { + m.lo.Error("error marking notification as processed", "error", err) + } + continue + } + case MetricNextResponse: + if slaEvent.ID == 0 { + m.lo.Warn("next response SLA event not found", "scheduled_notification_id", scheduledNotification.ID) + return fmt.Errorf("next response SLA event not found for notification: %d", scheduledNotification.ID) + } + if slaEvent.MetAt.Valid { + m.lo.Info("skipping notification as next response is already met", "applied_sla_id", appliedSLA.ID) if _, err := m.q.MarkNotificationProcessed.Exec(scheduledNotification.ID); err != nil { m.lo.Error("error marking notification as processed", "error", err) } @@ -349,6 +544,14 @@ func (m *Manager) SendNotification(scheduledNotification models.ScheduledSLANoti m.lo.Error("error parsing recipient ID", "error", err, "recipient_id", recipientS) continue } + + if recipientID == 0 { + if _, err := m.q.MarkNotificationProcessed.Exec(scheduledNotification.ID); err != nil { + m.lo.Error("error marking notification as processed", "error", err) + } + continue + } + agent, err := m.userStore.GetAgent(recipientID, "") if err != nil { m.lo.Error("error fetching agent for SLA notification", "recipient_id", recipientID, "error", err) @@ -378,7 +581,7 @@ func (m *Manager) SendNotification(scheduledNotification models.ScheduledSLANoti getFriendlyDuration := func(target time.Time) string { d := time.Until(target) if d < 0 { - return "Overdue by " + stringutil.FormatDuration(-d, false) + return stringutil.FormatDuration(-d, false) } return stringutil.FormatDuration(d, false) } @@ -387,9 +590,12 @@ func (m *Manager) SendNotification(scheduledNotification models.ScheduledSLANoti case MetricFirstResponse: dueIn = getFriendlyDuration(appliedSLA.FirstResponseDeadlineAt) overdueBy = getFriendlyDuration(appliedSLA.FirstResponseBreachedAt.Time) - case MetricsResolution: + case MetricResolution: dueIn = getFriendlyDuration(appliedSLA.ResolutionDeadlineAt) overdueBy = getFriendlyDuration(appliedSLA.ResolutionBreachedAt.Time) + case MetricNextResponse: + dueIn = getFriendlyDuration(slaEvent.DeadlineAt) + overdueBy = getFriendlyDuration(slaEvent.BreachedAt.Time) default: m.lo.Error("unknown metric type", "metric", scheduledNotification.Metric) return fmt.Errorf("unknown metric type: %s", scheduledNotification.Metric) @@ -446,7 +652,7 @@ func (m *Manager) SendNotification(scheduledNotification models.ScheduledSLANoti m.lo.Error("error sending email notification", "error", err) } - // Set the notification as processed. + // Mark the notification as processed. if _, err := m.q.MarkNotificationProcessed.Exec(scheduledNotification.ID); err != nil { m.lo.Error("error marking notification as processed", "error", err) } @@ -512,14 +718,14 @@ func (m *Manager) getBusinessHoursAndTimezone(assignedTeamID int) (bmodels.Busin } // createNotificationSchedule creates a notification schedule in database for the applied SLA. -func (m *Manager) createNotificationSchedule(notifications models.SlaNotifications, appliedSLAID int, deadlines Deadlines, breaches Breaches) { +func (m *Manager) createNotificationSchedule(notifications models.SlaNotifications, appliedSLAID int, slaEventID null.Int, deadlines Deadlines, breaches Breaches) { 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) return } - - if _, err := m.q.InsertScheduledSLANotification.Exec(appliedSLAID, metric, notifType, pq.Array(recipients), sendAt); err != nil { + if _, err := m.q.InsertScheduledSLANotification.Exec(appliedSLAID, slaEventID, metric, notifType, pq.Array(recipients), sendAt); err != nil { m.lo.Error("error inserting scheduled SLA notification", "error", err) } } @@ -547,14 +753,20 @@ func (m *Manager) createNotificationSchedule(notifications models.SlaNotificatio scheduleNotification(deadlines.FirstResponse.Add(-delayDur), MetricFirstResponse, notif.Type, notif.Recipients) } if !deadlines.Resolution.IsZero() { - scheduleNotification(deadlines.Resolution.Add(-delayDur), MetricsResolution, notif.Type, notif.Recipients) + scheduleNotification(deadlines.Resolution.Add(-delayDur), MetricResolution, notif.Type, notif.Recipients) + } + if !deadlines.NextResponse.IsZero() { + scheduleNotification(deadlines.NextResponse.Add(-delayDur), MetricNextResponse, notif.Type, notif.Recipients) } } else if notif.Type == NotificationTypeBreach { if !breaches.FirstResponse.IsZero() { scheduleNotification(breaches.FirstResponse.Add(delayDur), MetricFirstResponse, notif.Type, notif.Recipients) } if !breaches.Resolution.IsZero() { - scheduleNotification(breaches.Resolution.Add(delayDur), MetricsResolution, notif.Type, notif.Recipients) + scheduleNotification(breaches.Resolution.Add(delayDur), MetricResolution, notif.Type, notif.Recipients) + } + if !breaches.NextResponse.IsZero() { + scheduleNotification(breaches.NextResponse.Add(delayDur), MetricNextResponse, notif.Type, notif.Recipients) } } } @@ -626,8 +838,8 @@ func (m *Manager) evaluateSLA(sla models.AppliedSLA) error { // If resolution is not breached and not met, check the deadine and set them. if !sla.ResolutionBreachedAt.Valid && !sla.ResolutionMetAt.Valid { - m.lo.Debug("checking deadline", "deadline", sla.ResolutionDeadlineAt, "met_at", sla.ConversationResolvedAt.Time, "metric", MetricsResolution) - if err := checkDeadline(sla.ResolutionDeadlineAt, sla.ConversationResolvedAt, MetricsResolution); err != nil { + m.lo.Debug("checking deadline", "deadline", sla.ResolutionDeadlineAt, "met_at", sla.ConversationResolvedAt.Time, "metric", MetricResolution) + if err := checkDeadline(sla.ResolutionDeadlineAt, sla.ConversationResolvedAt, MetricResolution); err != nil { return err } } @@ -661,12 +873,12 @@ func (m *Manager) updateBreachAt(appliedSLAID, slaPolicyID int, metric string) e var firstResponse, resolution time.Time if metric == MetricFirstResponse { firstResponse = time.Now() - } else if metric == MetricsResolution { + } else if metric == MetricResolution { resolution = time.Now() } // Create notification schedule. - m.createNotificationSchedule(sla.Notifications, appliedSLAID, Deadlines{}, Breaches{ + m.createNotificationSchedule(sla.Notifications, appliedSLAID, null.Int{}, Deadlines{}, Breaches{ FirstResponse: firstResponse, Resolution: resolution, }) diff --git a/schema.sql b/schema.sql index cdffb76..3db86db 100644 --- a/schema.sql +++ b/schema.sql @@ -15,7 +15,8 @@ DROP TYPE IF EXISTS "media_disposition" CASCADE; CREATE TYPE "media_disposition" DROP TYPE IF EXISTS "media_store" CASCADE; CREATE TYPE "media_store" AS ENUM ('s3', 'fs'); DROP TYPE IF EXISTS "user_availability_status" CASCADE; CREATE TYPE "user_availability_status" AS ENUM ('online', 'away', 'away_manual', 'offline', 'away_and_reassigning'); DROP TYPE IF EXISTS "applied_sla_status" CASCADE; CREATE TYPE "applied_sla_status" AS ENUM ('pending', 'breached', 'met', 'partially_met'); -DROP TYPE IF EXISTS "sla_metric" CASCADE; CREATE TYPE "sla_metric" AS ENUM ('first_response', 'resolution'); +DROP TYPE IF EXISTS "sla_event_status" CASCADE; CREATE TYPE "sla_event_status" AS ENUM ('pending', 'breached', 'met'); +DROP TYPE IF EXISTS "sla_metric" CASCADE; CREATE TYPE "sla_metric" AS ENUM ('first_response', 'resolution', 'next_response'); DROP TYPE IF EXISTS "sla_notification_type" CASCADE; CREATE TYPE "sla_notification_type" AS ENUM ('warning', 'breach'); DROP TYPE IF EXISTS "activity_log_type" CASCADE; CREATE TYPE "activity_log_type" AS ENUM ('agent_login', 'agent_logout', 'agent_away', 'agent_away_reassigned', 'agent_online'); @@ -39,6 +40,7 @@ CREATE TABLE sla_policies ( description TEXT NULL, first_response_time TEXT NOT NULL, resolution_time TEXT NOT NULL, + next_response_time TEXT NULL, notifications JSONB DEFAULT '[]'::jsonb NOT NULL, CONSTRAINT constraint_sla_policies_on_name CHECK (length(name) <= 140), CONSTRAINT constraint_sla_policies_on_description CHECK (length(description) <= 300) @@ -201,7 +203,8 @@ CREATE TABLE conversations ( -- Set to NULL when SLA policy is deleted. sla_policy_id INT REFERENCES sla_policies(id) ON DELETE SET NULL ON UPDATE CASCADE, - + applied_sla_id BIGINT REFERENCES applied_slas(id) ON DELETE SET NULL ON UPDATE CASCADE, + -- Cascade deletes when inbox is deleted. inbox_id INT REFERENCES inboxes(id) ON DELETE CASCADE ON UPDATE CASCADE NOT NULL, @@ -374,6 +377,7 @@ CREATE TABLE team_members ( CONSTRAINT constraint_team_members_on_emoji CHECK (length(emoji) <= 1) ); CREATE UNIQUE INDEX index_unique_team_members_on_team_id_and_user_id ON team_members (team_id, user_id); +CREATE INDEX index_team_members_on_user_id ON team_members (user_id); DROP TABLE IF EXISTS templates CASCADE; CREATE TABLE templates ( @@ -456,12 +460,29 @@ CREATE TABLE applied_slas ( CREATE INDEX index_applied_slas_on_conversation_id ON applied_slas(conversation_id); CREATE INDEX index_applied_slas_on_status ON applied_slas(status); +DROP TABLE IF EXISTS sla_events CASCADE; +CREATE TABLE sla_events ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + status sla_event_status DEFAULT 'pending' NOT NULL, + applied_sla_id BIGINT REFERENCES applied_slas(id) ON DELETE CASCADE ON UPDATE CASCADE NOT NULL, + sla_policy_id INT REFERENCES sla_policies(id) ON DELETE CASCADE ON UPDATE CASCADE NOT NULL, + type sla_metric NOT NULL, + deadline_at TIMESTAMPTZ NOT NULL, + met_at TIMESTAMPTZ, + breached_at TIMESTAMPTZ +); +CREATE INDEX index_sla_events_on_applied_sla_id ON sla_events(applied_sla_id); +CREATE INDEX index_sla_events_on_status ON sla_events(status); + DROP TABLE IF EXISTS scheduled_sla_notifications CASCADE; CREATE TABLE scheduled_sla_notifications ( id BIGSERIAL PRIMARY KEY, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), applied_sla_id BIGINT NOT NULL REFERENCES applied_slas(id) ON DELETE CASCADE, + sla_event_id BIGINT REFERENCES sla_events(id) ON DELETE CASCADE, metric sla_metric NOT NULL, notification_type sla_notification_type NOT NULL, recipients TEXT[] NOT NULL,