wip: next response metric for sla

This commit is contained in:
Abhinav Raut
2025-05-23 03:45:57 +05:30
parent cb1ec7eb8e
commit 4c766d8ccb
18 changed files with 623 additions and 133 deletions

View File

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

View File

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

View File

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

View File

@@ -44,6 +44,19 @@
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="next_response_time">
<FormItem>
<FormLabel>{{ t('admin.sla.nextResponseTime') }}</FormLabel>
<FormControl>
<Input type="text" placeholder="30m" v-bind="componentField" />
</FormControl>
<FormDescription>
{{ t('admin.sla.nextResponseTime.description') }}
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Notifications Section -->
<div class="space-y-6">
<div class="flex items-center justify-between pb-3 border-b">
@@ -93,7 +106,10 @@
</span>
<div>
<div class="font-medium text-foreground">
{{ notification.type === 'warning' ? t('admin.sla.warning') : t('admin.sla.breach') }} {{ t('admin.sla.notification') }}
{{
notification.type === 'warning' ? t('admin.sla.warning') : t('admin.sla.breach')
}}
{{ t('admin.sla.notification') }}
</div>
<p class="text-xs text-muted-foreground">
{{ notification.type === 'warning' ? 'Pre-breach alert' : 'Post-breach action' }}
@@ -149,7 +165,11 @@
<FormItem v-if="shouldShowTimeDelay(index)">
<FormLabel class="flex items-center gap-1.5 text-sm font-medium">
<Hourglass class="w-4 h-4 text-muted-foreground" />
{{ notification.type === 'warning' ? t('admin.sla.advanceWarning') : t('admin.sla.followUpDelay') }}
{{
notification.type === 'warning'
? t('admin.sla.advanceWarning')
: t('admin.sla.followUpDelay')
}}
</FormLabel>
<FormControl>
<Select v-bind="componentField" class="hover:border-foreground/30">
@@ -274,7 +294,10 @@ const props = defineProps({
const usersStore = useUsersStore()
const submitLabel = computed(() => {
return props.submitLabel || (props.initialValues.id ? t('globals.buttons.update') : t('globals.buttons.create'))
return (
props.submitLabel ||
(props.initialValues.id ? t('globals.buttons.update') : t('globals.buttons.create'))
)
})
const delayDurations = [
'5m',

View File

@@ -18,6 +18,10 @@ export const createFormSchema = (t) => z.object({
message:
t('globals.messages.goHourMinuteDuration'),
}),
next_response_time: z.string().refine(isGoHourMinuteDuration, {
message:
t('globals.messages.goHourMinuteDuration'),
}),
notifications: z
.array(
z

View File

@@ -55,6 +55,10 @@
</div>
</div>
{{ conversation.next_response_deadline_at }}
<br />
{{ conversation.last_reply_at }}
<div class="flex items-center mt-2 space-x-2">
<SlaBadge
v-if="conversation.first_response_deadline_at"
@@ -70,6 +74,16 @@
:label="'RD'"
:showExtra="false"
/>
<SlaBadge
:dueAt="conversation.next_response_deadline_at"
:actualAt="
['met'].includes(conversation.next_response_sla_event_status)
? conversation.last_reply_at
: null
"
:label="'NRD'"
:showExtra="false"
/>
</div>
</div>
</div>

View File

@@ -1,9 +1,6 @@
<template>
<div class="space-y-4">
<div
class="flex flex-col"
v-if="conversation.subject"
>
<div class="flex flex-col" v-if="conversation.subject">
<p class="font-medium">{{ $t('form.field.subject') }}</p>
<Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4" />
<p v-else>
@@ -66,7 +63,19 @@
</div>
<div class="flex flex-col">
<p class="font-medium">{{ $t('form.field.lastReplyAt') }}</p>
<div class="flex justify-start items-center space-x-2">
<p class="font-medium">{{ $t('form.field.lastReplyAt') }}</p>
<SlaBadge
v-if="conversation.next_response_deadline_at"
:dueAt="conversation.next_response_deadline_at"
:actualAt="
['met'].includes(conversation.next_response_sla_event_status)
? conversation.last_reply_at
: null
"
:key="conversation.uuid"
/>
</div>
<Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4" />
<p v-if="conversation.last_reply_at">
{{ format(conversation.last_reply_at, 'PPpp') }}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,8 +130,10 @@ 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
@@ -133,3 +141,48 @@ UPDATE scheduled_sla_notifications
SET processed_at = NOW(),
updated_at = NOW()
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;

View File

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

View File

@@ -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,6 +203,7 @@ 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,