feat: configurable SLA alerts per SLA.

This commit is contained in:
Abhinav Raut
2025-03-21 23:23:03 +05:30
parent f0358f67f0
commit aeef7d4ad7
13 changed files with 830 additions and 251 deletions

View File

@@ -12,10 +12,6 @@ import (
"github.com/zerodha/fastglue"
)
var (
slaReqFields = map[string][2]int{"name": {1, 255}, "description": {1, 255}, "first_response_time": {1, 255}, "resolution_time": {1, 255}}
)
// initHandlers initializes the HTTP routes and handlers for the application.
func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
// Authentication.
@@ -169,8 +165,8 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
// SLA.
g.GET("/api/v1/sla", perm(handleGetSLAs, "sla:manage"))
g.GET("/api/v1/sla/{id}", perm(handleGetSLA, "sla:manage"))
g.POST("/api/v1/sla", perm(fastglue.ReqLenRangeParams(handleCreateSLA, slaReqFields), "sla:manage"))
g.PUT("/api/v1/sla/{id}", perm(fastglue.ReqLenRangeParams(handleUpdateSLA, slaReqFields), "sla:manage"))
g.POST("/api/v1/sla", perm(handleCreateSLA, "sla:manage"))
g.PUT("/api/v1/sla/{id}", perm(handleUpdateSLA, "sla:manage"))
g.DELETE("/api/v1/sla/{id}", perm(handleDeleteSLA, "sla:manage"))
// AI completion.

View File

@@ -283,12 +283,12 @@ func initBusinessHours(db *sqlx.DB) *businesshours.Manager {
}
// initSLA inits SLA manager.
func initSLA(db *sqlx.DB, teamManager *team.Manager, settings *setting.Manager, businessHours *businesshours.Manager) *sla.Manager {
func initSLA(db *sqlx.DB, teamManager *team.Manager, settings *setting.Manager, businessHours *businesshours.Manager, notifier *notifier.Service, template *tmpl.Manager, userManager *user.Manager) *sla.Manager {
var lo = initLogger("sla")
m, err := sla.New(sla.Opts{
DB: db,
Lo: lo,
}, teamManager, settings, businessHours)
}, teamManager, settings, businessHours, notifier, template, userManager)
if err != nil {
log.Fatalf("error initializing SLA manager: %v", err)
}
@@ -496,13 +496,13 @@ func initAutoAssigner(teamManager *team.Manager, userManager *user.Manager, conv
}
// initNotifier initializes the notifier service with available providers.
func initNotifier(userStore notifier.UserStore) *notifier.Service {
func initNotifier() *notifier.Service {
smtpCfg := email.SMTPConfig{}
if err := ko.UnmarshalWithConf("notification.email", &smtpCfg, koanf.UnmarshalConf{Tag: "json"}); err != nil {
log.Fatalf("error unmarshalling email notification provider config: %v", err)
}
emailNotifier, err := emailnotifier.New([]email.SMTPConfig{smtpCfg}, userStore, emailnotifier.Opts{
emailNotifier, err := emailnotifier.New([]email.SMTPConfig{smtpCfg}, emailnotifier.Opts{
Lo: initLogger("email-notifier"),
FromEmail: ko.String("notification.email.email_address"),
})

View File

@@ -178,9 +178,9 @@ func main() {
businessHours = initBusinessHours(db)
user = initUser(i18n, db)
wsHub = initWS(user)
notifier = initNotifier(user)
notifier = initNotifier()
automation = initAutomationEngine(db)
sla = initSLA(db, team, settings, businessHours)
sla = initSLA(db, team, settings, businessHours, notifier, template, user)
conversation = initConversations(i18n, sla, status, priority, wsHub, notifier, db, inbox, user, team, media, settings, csat, automation, template)
autoassigner = initAutoAssigner(team, user, conversation)
)
@@ -193,6 +193,7 @@ func main() {
go conversation.RunUnsnoozer(ctx, unsnoozeInterval)
go notifier.Run(ctx)
go sla.Run(ctx, slaEvaluationInterval)
go sla.SendNotifications(ctx)
go media.DeleteUnlinkedMedia(ctx)
go user.MonitorAgentAvailability(ctx)

View File

@@ -5,6 +5,7 @@ import (
"time"
"github.com/abhinavxd/libredesk/internal/envelope"
smodels "github.com/abhinavxd/libredesk/internal/sla/models"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
@@ -41,30 +42,52 @@ func handleGetSLA(r *fastglue.Request) error {
// handleCreateSLA creates a new SLA.
func handleCreateSLA(r *fastglue.Request) error {
var (
app = r.Context.(*App)
name = string(r.RequestCtx.PostArgs().Peek("name"))
desc = string(r.RequestCtx.PostArgs().Peek("description"))
firstRespTime = string(r.RequestCtx.PostArgs().Peek("first_response_time"))
resTime = string(r.RequestCtx.PostArgs().Peek("resolution_time"))
app = r.Context.(*App)
sla smodels.SLAPolicy
)
// Validate time duration strings
frt, err := time.ParseDuration(firstRespTime)
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `first_response_time` duration.", nil, envelope.InputError)
if err := r.Decode(&sla, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
}
rt, err := time.ParseDuration(resTime)
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `resolution_time` duration.", nil, envelope.InputError)
}
if frt > rt {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "`first_response_time` should be less than `resolution_time`.", nil, envelope.InputError)
}
if err := app.sla.Create(name, desc, firstRespTime, resTime); err != nil {
if err := validateSLA(&sla); err != nil {
return sendErrorEnvelope(r, err)
}
if err := app.sla.Create(sla.Name, sla.Description, sla.FirstResponseTime, sla.ResolutionTime, sla.Notifications); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("SLA created successfully.")
}
// handleUpdateSLA updates the SLA with the given ID.
func handleUpdateSLA(r *fastglue.Request) error {
var (
app = r.Context.(*App)
sla smodels.SLAPolicy
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid SLA `id`.", nil, envelope.InputError)
}
if err := r.Decode(&sla, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
}
if err := validateSLA(&sla); err != nil {
return sendErrorEnvelope(r, err)
}
if err := app.sla.Update(id, sla.Name, sla.Description, sla.FirstResponseTime, sla.ResolutionTime, sla.Notifications); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("SLA updated successfully.")
}
// handleDeleteSLA deletes the SLA with the given ID.
func handleDeleteSLA(r *fastglue.Request) error {
var (
@@ -82,33 +105,55 @@ func handleDeleteSLA(r *fastglue.Request) error {
return r.SendEnvelope(true)
}
// handleUpdateSLA updates the SLA with the given ID.
func handleUpdateSLA(r *fastglue.Request) error {
var (
app = r.Context.(*App)
name = string(r.RequestCtx.PostArgs().Peek("name"))
desc = string(r.RequestCtx.PostArgs().Peek("description"))
firstRespTime = string(r.RequestCtx.PostArgs().Peek("first_response_time"))
resTime = string(r.RequestCtx.PostArgs().Peek("resolution_time"))
)
// Validate time duration strings
frt, err := time.ParseDuration(firstRespTime)
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `first_response_time` duration.", nil, envelope.InputError)
// validateSLA validates the SLA policy and returns an envelope.Error if any validation fails.
func validateSLA(sla *smodels.SLAPolicy) error {
if sla.Name == "" {
return envelope.NewError(envelope.InputError, "SLA `name` is required", nil)
}
rt, err := time.ParseDuration(resTime)
if sla.FirstResponseTime == "" {
return envelope.NewError(envelope.InputError, "SLA `first_response_time` is required", nil)
}
if sla.ResolutionTime == "" {
return envelope.NewError(envelope.InputError, "SLA `resolution_time` is required", nil)
}
// Validate notifications if any
for _, n := range sla.Notifications {
if n.Type == "" {
return envelope.NewError(envelope.InputError, "SLA notification `type` is required", nil)
}
if n.TimeDelayType == "" {
return envelope.NewError(envelope.InputError, "SLA notification `time_delay_type` is required", nil)
}
if n.TimeDelayType != "immediately" {
if n.TimeDelay == "" {
return envelope.NewError(envelope.InputError, "SLA notification `time_delay` is required", nil)
}
}
if len(n.Recipients) == 0 {
return envelope.NewError(envelope.InputError, "SLA notification `recipients` is required", nil)
}
}
// Validate time duration strings
frt, err := time.ParseDuration(sla.FirstResponseTime)
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `resolution_time` duration.", nil, envelope.InputError)
return envelope.NewError(envelope.InputError, "Invalid `first_response_time` duration", nil)
}
if frt.Minutes() < 1 {
return envelope.NewError(envelope.InputError, "`first_response_time` should be greater than 1 minute", nil)
}
rt, err := time.ParseDuration(sla.ResolutionTime)
if err != nil {
return envelope.NewError(envelope.InputError, "Invalid `resolution_time` duration", nil)
}
if rt.Minutes() < 1 {
return envelope.NewError(envelope.InputError, "`resolution_time` should be greater than 1 minute", nil)
}
if frt > rt {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "`first_response_time` should be less than `resolution_time`.", nil, envelope.InputError)
return envelope.NewError(envelope.InputError, "`first_response_time` should be less than `resolution_time`", nil)
}
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid SLA `id`.", nil, envelope.InputError)
}
if err := app.sla.Update(id, name, desc, firstRespTime, resTime); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
return nil
}

View File

@@ -82,8 +82,16 @@ const deleteBusinessHours = (id) => http.delete(`/api/v1/business-hours/${id}`)
const getAllSLAs = () => http.get('/api/v1/sla')
const getSLA = (id) => http.get(`/api/v1/sla/${id}`)
const createSLA = (data) => http.post('/api/v1/sla', data)
const updateSLA = (id, data) => http.put(`/api/v1/sla/${id}`, data)
const createSLA = (data) => http.post('/api/v1/sla', data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateSLA = (id, data) => http.put(`/api/v1/sla/${id}`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const deleteSLA = (id) => http.delete(`/api/v1/sla/${id}`)
const createOIDC = (data) =>
http.post('/api/v1/oidc', data, {

View File

@@ -5,15 +5,11 @@ export const formSchema = z.object({
name: z
.string()
.min(1, { message: 'Name is required' })
.max(255, {
message: 'Name must be at most 255 characters.'
}),
.max(255, { message: 'Name must be at most 255 characters.' }),
description: z
.string()
.min(1, { message: 'Description is required' })
.max(255, {
message: 'Description must be at most 255 characters.'
}),
.max(255, { message: 'Description must be at most 255 characters.' }),
first_response_time: z.string().refine(isGoHourMinuteDuration, {
message:
'Invalid duration format. Should be a number followed by h (hours), m (minutes).'
@@ -22,4 +18,37 @@ export const formSchema = z.object({
message:
'Invalid duration format. Should be a number followed by h (hours), m (minutes).'
}),
notifications: z
.array(
z
.object({
type: z.enum(['breach', 'warning']),
time_delay_type: z.enum(['immediately', 'after', 'before']),
time_delay: z.string().optional(),
recipients: z
.array(z.string())
.min(1, { message: 'At least one recipient is required' })
})
.superRefine((obj, ctx) => {
if (obj.time_delay_type !== 'immediately') {
if (!obj.time_delay || obj.time_delay === '') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
'Delay is required',
path: ['time_delay']
})
} else if (!isGoHourMinuteDuration(obj.time_delay)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
'Invalid duration format. Should be a number followed by h (hours), m (minutes).',
path: ['time_delay']
})
}
}
})
)
.optional()
.default([])
})

View File

@@ -1,14 +1,18 @@
<template>
<div class="mb-5">
<CustomBreadcrumb :links="breadcrumbLinks" />
</div>
<Spinner v-if="isLoading"></Spinner>
<SLAForm :initial-values="slaData" :submitForm="submitForm" :isNewForm="isNewForm"
:class="{ 'opacity-50 transition-opacity duration-300': isLoading }" :isLoading="formLoading" />
<div class="mb-5">
<CustomBreadcrumb :links="breadcrumbLinks" />
</div>
<Spinner v-if="isLoading"></Spinner>
<SLAForm
:initial-values="slaData"
:submitForm="submitForm"
:class="{ 'opacity-50 transition-opacity duration-300': isLoading }"
:isLoading="formLoading"
/>
</template>
<script setup>
import { onMounted, ref, computed } from 'vue'
import { onMounted, ref } from 'vue'
import api from '@/api'
import SLAForm from '@/features/admin/sla/SLAForm.vue'
import { useRouter } from 'vue-router'
@@ -24,68 +28,64 @@ const isLoading = ref(false)
const formLoading = ref(false)
const router = useRouter()
const props = defineProps({
id: {
type: String,
required: false
}
id: {
type: String,
required: false
}
})
const submitForm = async (values) => {
try {
formLoading.value = true
if (props.id) {
await api.updateSLA(props.id, values)
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Success',
description: 'SLA updated successfully',
})
} else {
await api.createSLA(values)
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Success',
description: 'SLA created successfully',
})
router.push({ name: 'sla-list' })
}
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Could not save SLA',
variant: 'destructive',
description: handleHTTPError(error).message
})
} finally {
formLoading.value = false
try {
formLoading.value = true
if (props.id) {
await api.updateSLA(props.id, values)
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Success',
description: 'SLA updated successfully'
})
} else {
await api.createSLA(values)
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Success',
description: 'SLA created successfully'
})
router.push({ name: 'sla-list' })
}
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Could not save SLA',
variant: 'destructive',
description: handleHTTPError(error).message
})
} finally {
formLoading.value = false
}
}
const breadCrumLabel = () => {
return props.id ? 'Edit' : 'New'
return props.id ? 'Edit' : 'New'
}
const isNewForm = computed(() => {
return props.id ? false : true
})
const breadcrumbLinks = [
{ path: 'sla-list', label: 'SLA' },
{ path: '', label: breadCrumLabel() }
{ path: 'sla-list', label: 'SLA' },
{ path: '', label: breadCrumLabel() }
]
onMounted(async () => {
if (props.id) {
try {
isLoading.value = true
const resp = await api.getSLA(props.id)
slaData.value = resp.data.data
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Could not fetch SLA',
variant: 'destructive',
description: handleHTTPError(error).message
})
} finally {
isLoading.value = false
}
if (props.id) {
try {
isLoading.value = true
const resp = await api.getSLA(props.id)
slaData.value = resp.data.data
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Could not fetch SLA',
variant: 'destructive',
description: handleHTTPError(error).message
})
} finally {
isLoading.value = false
}
}
})
</script>

View File

@@ -1,27 +1,76 @@
package models
import (
"database/sql/driver"
"encoding/json"
"fmt"
"time"
"github.com/lib/pq"
"github.com/volatiletech/null/v9"
)
// SLAPolicy represents a service level agreement policy definition
type SLAPolicy 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"`
Name string `db:"name" json:"name"`
Description string `db:"description" json:"description"`
FirstResponseTime string `db:"first_response_time" json:"first_response_time"`
EveryResponseTime string `db:"every_response_time" json:"every_response_time"`
ResolutionTime string `db:"resolution_time" json:"resolution_time"`
ID int `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
Name string `db:"name" json:"name"`
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"`
ResolutionTime string `db:"resolution_time" json:"resolution_time,omitempty"`
Notifications SlaNotifications `db:"notifications" json:"notifications,omitempty"`
}
// AppliedSLA represents an SLA policy applied to a conversation with its deadlines and breach status
type SlaNotifications []SlaNotification
// Value implements the driver.Valuer interface.
func (sn SlaNotifications) Value() (driver.Value, error) {
return json.Marshal(sn)
}
// Scan implements the sql.Scanner interface.
func (sn *SlaNotifications) Scan(src any) error {
var data []byte
switch v := src.(type) {
case string:
data = []byte(v)
case []byte:
data = v
default:
return fmt.Errorf("unsupported type: %T", src)
}
return json.Unmarshal(data, sn)
}
// SlaNotification represents the notification settings for an SLA policy
type SlaNotification struct {
Type string `db:"type" json:"type"`
Recipients []string `db:"recipients" json:"recipients"`
TimeDelay string `db:"time_delay" json:"time_delay"`
TimeDelayType string `db:"time_delay_type" json:"time_delay_type"`
}
// ScheduledSLANotification represents a scheduled SLA notification
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"`
AppliedSLAID int `db:"applied_sla_id" json:"applied_sla_id"`
Metric string `db:"metric" json:"metric"`
NotificationType string `db:"notification_type" json:"notification_type"`
Recipients pq.StringArray `db:"recipients" json:"recipients"`
SendAt time.Time `db:"send_at" json:"send_at"`
ProcessedAt null.Time `db:"processed_at" json:"processed_at,omitempty"`
}
// AppliedSLA represents an SLA policy applied to a conversation
type AppliedSLA struct {
ID int `db:"id"`
CreatedAt time.Time `db:"created_at"`
Status string `db:"status"`
ConversationID int `db:"conversation_id"`
SLAPolicyID int `db:"sla_policy_id"`
FirstResponseDeadlineAt time.Time `db:"first_response_deadline_at"`
@@ -31,6 +80,11 @@ type AppliedSLA struct {
FirstResponseMetAt null.Time `db:"first_response_met_at"`
ResolutionMetAt null.Time `db:"resolution_met_at"`
// Conversation fields.
ConversationFirstResponseAt null.Time `db:"conversation_first_response_at"`
ConversationResolvedAt null.Time `db:"conversation_resolved_at"`
ConversationUUID string `db:"conversation_uuid"`
ConversationReferenceNumber string `db:"conversation_reference_number"`
ConversationSubject string `db:"conversation_subject"`
ConversationAssignedUserID null.Int `db:"conversation_assigned_user_id"`
}

View File

@@ -1,19 +1,17 @@
-- name: get-sla-policy
SELECT * FROM sla_policies WHERE id = $1;
SELECT id, name, description, first_response_time, resolution_time, notifications, created_at, updated_at FROM sla_policies WHERE id = $1;
-- name: get-all-sla-policies
SELECT * FROM sla_policies ORDER BY updated_at DESC;
SELECT id, name, created_at, updated_at FROM sla_policies ORDER BY updated_at DESC;
-- name: insert-sla-policy
INSERT INTO sla_policies (
name,
description,
first_response_time,
resolution_time
) VALUES ($1, $2, $3, $4);
-- name: delete-sla-policy
DELETE FROM sla_policies WHERE id = $1;
resolution_time,
notifications
) VALUES ($1, $2, $3, $4, $5);
-- name: update-sla-policy
UPDATE sla_policies SET
@@ -21,30 +19,33 @@ UPDATE sla_policies SET
description = $3,
first_response_time = $4,
resolution_time = $5,
notifications = $6,
updated_at = NOW()
WHERE id = $1;
-- name: delete-sla-policy
DELETE FROM sla_policies WHERE id = $1;
-- name: apply-sla
WITH new_sla AS (
INSERT INTO applied_slas (
conversation_id,
sla_policy_id,
first_response_deadline_at,
resolution_deadline_at
) VALUES ($1, $2, $3, $4)
RETURNING conversation_id
INSERT INTO applied_slas (
conversation_id,
sla_policy_id,
first_response_deadline_at,
resolution_deadline_at
) VALUES ($1, $2, $3, $4)
RETURNING conversation_id, id
)
UPDATE conversations
UPDATE conversations c
SET sla_policy_id = $2,
next_sla_deadline_at = LEAST(
NULLIF($3, NULL),
NULLIF($4, NULL)
)
WHERE id IN (SELECT conversation_id FROM new_sla);
next_sla_deadline_at = LEAST($3, $4)
FROM new_sla ns
WHERE c.id = ns.conversation_id
RETURNING ns.id;
-- name: get-pending-slas
-- Get all the applied SLAs (applied to a conversation) that are pending
SELECT a.id, a.first_response_deadline_at, c.first_reply_at as conversation_first_response_at,
SELECT a.id, a.first_response_deadline_at, c.first_reply_at as conversation_first_response_at, a.sla_policy_id,
a.resolution_deadline_at, c.resolved_at as conversation_resolved_at, c.id as conversation_id, a.first_response_met_at, a.resolution_met_at, a.first_response_breached_at, a.resolution_breached_at
FROM applied_slas a
JOIN conversations c ON a.conversation_id = c.id and c.sla_policy_id = a.sla_policy_id
@@ -90,3 +91,45 @@ SET
END,
updated_at = NOW()
WHERE applied_slas.id = $1;
-- name: insert-scheduled-sla-notification
INSERT INTO scheduled_sla_notifications (
applied_sla_id,
metric,
notification_type,
recipients,
send_at
) VALUES ($1, $2, $3, $4, $5);
-- name: get-scheduled-sla-notifications
SELECT id, created_at, updated_at, applied_sla_id, metric, notification_type, recipients, send_at, processed_at
FROM scheduled_sla_notifications
WHERE send_at <= NOW() AND processed_at IS NULL;
-- name: get-applied-sla
SELECT a.id,
a.created_at,
a.updated_at,
a.conversation_id,
a.sla_policy_id,
a.first_response_deadline_at,
a.resolution_deadline_at,
a.first_response_met_at,
a.resolution_met_at,
a.first_response_breached_at,
a.resolution_breached_at,
a.status,
c.first_reply_at as conversation_first_response_at,
c.resolved_at as conversation_resolved_at,
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
WHERE a.id = $1;
-- name: mark-notification-processed
UPDATE scheduled_sla_notifications
SET processed_at = NOW(),
updated_at = NOW()
WHERE id = $1;

View File

@@ -2,6 +2,7 @@ package sla
import (
"context"
"database/sql"
"embed"
"encoding/json"
"fmt"
@@ -9,14 +10,19 @@ import (
"sync"
"time"
businessHours "github.com/abhinavxd/libredesk/internal/business_hours"
businesshours "github.com/abhinavxd/libredesk/internal/business_hours"
bmodels "github.com/abhinavxd/libredesk/internal/business_hours/models"
"github.com/abhinavxd/libredesk/internal/dbutil"
"github.com/abhinavxd/libredesk/internal/envelope"
notifier "github.com/abhinavxd/libredesk/internal/notification"
models "github.com/abhinavxd/libredesk/internal/sla/models"
"github.com/abhinavxd/libredesk/internal/stringutil"
tmodels "github.com/abhinavxd/libredesk/internal/team/models"
"github.com/abhinavxd/libredesk/internal/template"
umodels "github.com/abhinavxd/libredesk/internal/user/models"
"github.com/jmoiron/sqlx"
"github.com/jmoiron/sqlx/types"
"github.com/lib/pq"
"github.com/volatiletech/null/v9"
"github.com/zerodha/logf"
)
@@ -27,17 +33,28 @@ var (
)
const (
SLATypeFirstResponse = "first_response"
SLATypeResolution = "resolution"
MetricFirstResponse = "first_response"
MetricsResolution = "resolution"
NotificationTypeWarning = "warning"
NotificationTypeBreach = "breach"
)
var metricLabels = map[string]string{
MetricFirstResponse: "First Response",
MetricsResolution: "Resolution",
}
// Manager manages SLA policies and calculations.
type Manager struct {
q queries
lo *logf.Logger
teamStore teamStore
userStore userStore
appSettingsStore appSettingsStore
businessHrsStore businessHrsStore
notifier *notifier.Service
template *template.Manager
wg sync.WaitGroup
opts Opts
}
@@ -54,10 +71,20 @@ type Deadlines struct {
Resolution time.Time
}
// Breaches holds the breach timestamps for an SLA policy.
type Breaches struct {
FirstResponse time.Time
Resolution time.Time
}
type teamStore interface {
Get(id int) (tmodels.Team, error)
}
type userStore interface {
GetAgent(int) (umodels.User, error)
}
type appSettingsStore interface {
GetByPrefix(prefix string) (types.JSONText, error)
}
@@ -68,32 +95,39 @@ 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"`
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"`
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"`
}
// New creates a new SLA manager.
func New(opts Opts, teamStore teamStore, appSettingsStore appSettingsStore, businessHrsStore businessHrsStore) (*Manager, error) {
func New(opts Opts, teamStore teamStore, appSettingsStore appSettingsStore, businessHrsStore businessHrsStore, notifier *notifier.Service, template *template.Manager, userStore userStore) (*Manager, error) {
var q queries
if err := dbutil.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
return nil, err
}
return &Manager{q: q, lo: opts.Lo, teamStore: teamStore, appSettingsStore: appSettingsStore, businessHrsStore: businessHrsStore, opts: opts}, nil
return &Manager{q: q, lo: opts.Lo, teamStore: teamStore, appSettingsStore: appSettingsStore, businessHrsStore: businessHrsStore, notifier: notifier, template: template, userStore: userStore, opts: opts}, nil
}
// Get retrieves an SLA by ID.
func (m *Manager) Get(id int) (models.SLAPolicy, error) {
var sla models.SLAPolicy
if err := m.q.GetSLA.Get(&sla, id); err != nil {
if err == sql.ErrNoRows {
return sla, envelope.NewError(envelope.NotFoundError, "SLA not found", nil)
}
m.lo.Error("error fetching SLA", "error", err)
return sla, envelope.NewError(envelope.GeneralError, "Error fetching SLA", nil)
}
@@ -111,14 +145,23 @@ func (m *Manager) GetAll() ([]models.SLAPolicy, error) {
}
// Create creates a new SLA policy.
func (m *Manager) Create(name, description string, firstResponseTime, resolutionTime string) error {
if _, err := m.q.InsertSLA.Exec(name, description, firstResponseTime, resolutionTime); err != nil {
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 {
m.lo.Error("error inserting SLA", "error", err)
return envelope.NewError(envelope.GeneralError, "Error creating SLA", nil)
}
return nil
}
// 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 {
m.lo.Error("error updating SLA", "error", err)
return envelope.NewError(envelope.GeneralError, "Error updating SLA", nil)
}
return nil
}
// Delete deletes an SLA policy.
func (m *Manager) Delete(id int) error {
if _, err := m.q.DeleteSLA.Exec(id); err != nil {
@@ -128,69 +171,8 @@ func (m *Manager) Delete(id int) error {
return nil
}
// Update updates an existing SLA policy.
func (m *Manager) Update(id int, name, description string, firstResponseTime, resolutionTime string) error {
if _, err := m.q.UpdateSLA.Exec(id, name, description, firstResponseTime, resolutionTime); err != nil {
m.lo.Error("error updating SLA", "error", err)
return envelope.NewError(envelope.GeneralError, "Error updating SLA", nil)
}
return nil
}
// getBusinessHoursAndTimezone returns the business hours ID and timezone for a team, falling back to app settings.
func (m *Manager) getBusinessHoursAndTimezone(assignedTeamID int) (bmodels.BusinessHours, string, error) {
var (
businessHrsID int
timezone string
bh bmodels.BusinessHours
)
// Fetch from team if assignedTeamID is provided.
if assignedTeamID != 0 {
team, err := m.teamStore.Get(assignedTeamID)
if err != nil {
return bh, "", err
}
businessHrsID = team.BusinessHoursID.Int
timezone = team.Timezone
}
// Else fetch from app settings, this is System default.
if businessHrsID == 0 || timezone == "" {
settingsJ, err := m.appSettingsStore.GetByPrefix("app")
if err != nil {
return bh, "", err
}
var out map[string]interface{}
if err := json.Unmarshal([]byte(settingsJ), &out); err != nil {
return bh, "", fmt.Errorf("parsing settings: %v", err)
}
businessHrsIDStr, _ := out["app.business_hours_id"].(string)
businessHrsID, _ = strconv.Atoi(businessHrsIDStr)
timezone, _ = out["app.timezone"].(string)
}
// If still not found, return error.
if businessHrsID == 0 || timezone == "" {
return bh, "", fmt.Errorf("business hours or timezone not configured")
}
bh, err := m.businessHrsStore.Get(businessHrsID)
if err != nil {
if err == businessHours.ErrBusinessHoursNotFound {
m.lo.Warn("business hours not found", "team_id", assignedTeamID)
return bh, "", fmt.Errorf("business hours not found")
}
m.lo.Error("error fetching business hours for SLA", "error", err)
return bh, "", err
}
return bh, timezone, nil
}
// CalculateDeadline calculates the deadline for a given start time and duration.
func (m *Manager) CalculateDeadlines(startTime time.Time, slaPolicyID, assignedTeamID int) (Deadlines, 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) {
var deadlines Deadlines
businessHrs, timezone, err := m.getBusinessHoursAndTimezone(assignedTeamID)
@@ -230,27 +212,37 @@ func (m *Manager) CalculateDeadlines(startTime time.Time, slaPolicyID, assignedT
return deadlines, nil
}
// ApplySLA applies an SLA policy to a conversation.
// ApplySLA applies an SLA policy to a conversation by calculating and setting the deadlines.
func (m *Manager) ApplySLA(startTime time.Time, conversationID, assignedTeamID, slaPolicyID int) (models.SLAPolicy, error) {
var sla models.SLAPolicy
deadlines, err := m.CalculateDeadlines(startTime, slaPolicyID, assignedTeamID)
// Get deadlines for the SLA policy and assigned team.
deadlines, err := m.GetDeadlines(startTime, slaPolicyID, assignedTeamID)
if err != nil {
return sla, err
}
if _, err := m.q.ApplySLA.Exec(
// Insert applied SLA entry.
var appliedSLAID int
if err := m.q.ApplySLA.QueryRowx(
conversationID,
slaPolicyID,
deadlines.FirstResponse,
deadlines.Resolution,
); err != nil {
).Scan(&appliedSLAID); err != nil {
m.lo.Error("error applying SLA", "error", err)
return sla, envelope.NewError(envelope.GeneralError, "Error applying SLA", nil)
}
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{})
return sla, nil
}
@@ -275,14 +267,293 @@ func (m *Manager) Run(ctx context.Context, evalInterval time.Duration) {
}
}
// SendNotifications picks scheduled SLA notifications from the database and sends them to agents as emails.
func (m *Manager) SendNotifications(ctx context.Context) error {
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
var notifications []models.ScheduledSLANotification
if err := m.q.GetScheduledSLANotifications.SelectContext(ctx, &notifications); err != nil {
if err == ctx.Err() {
return err
}
m.lo.Error("error fetching scheduled SLA notifications", "error", err)
} else {
m.lo.Debug("found scheduled SLA notifications", "count", len(notifications))
for _, notification := range notifications {
// Exit early if context is done.
select {
case <-ctx.Done():
return ctx.Err()
default:
if err := m.SendNotification(notification); err != nil {
m.lo.Error("error sending notification", "error", err)
}
}
}
if len(notifications) > 0 {
m.lo.Debug("sent SLA notifications", "count", len(notifications))
}
}
// Sleep for short duration to avoid hammering the database.
time.Sleep(30 * time.Second)
}
}
}
// SendNotification sends a SLA notification to agents.
func (m *Manager) SendNotification(scheduledNotification models.ScheduledSLANotification) error {
var appliedSLA models.AppliedSLA
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)
}
// 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)
if _, err := m.q.MarkNotificationProcessed.Exec(scheduledNotification.ID); err != nil {
m.lo.Error("error marking notification as processed", "error", err)
}
continue
}
case MetricsResolution:
if appliedSLA.ResolutionMetAt.Valid {
m.lo.Debug("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
}
default:
m.lo.Error("unknown metric type", "metric", scheduledNotification.Metric)
continue
}
// Get recipient agent, recipient can be a specific agent or assigned user.
recipientID, err := strconv.Atoi(recipientS)
if recipientS == "assigned_user" {
recipientID = appliedSLA.ConversationAssignedUserID.Int
} else if err != nil {
m.lo.Error("error parsing recipient ID", "error", err, "recipient_id", recipientS)
continue
}
agent, err := m.userStore.GetAgent(recipientID)
if err != nil {
m.lo.Error("error fetching agent for SLA notification", "recipient_id", recipientID, "error", err)
continue
}
var (
dueIn, overdueBy string
tmpl string
)
// Set the template based on the notification type.
switch scheduledNotification.NotificationType {
case NotificationTypeBreach:
tmpl = template.TmplSLABreached
case NotificationTypeWarning:
tmpl = template.TmplSLABreachWarning
default:
m.lo.Error("unknown notification type", "notification_type", scheduledNotification.NotificationType)
return fmt.Errorf("unknown notification type: %s", scheduledNotification.NotificationType)
}
// Set the dueIn and overdueBy values based on the metric.
// These are relative to the current time as setting exact time would require agent's timezone.
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)
}
switch scheduledNotification.Metric {
case MetricFirstResponse:
dueIn = getFriendlyDuration(appliedSLA.FirstResponseDeadlineAt)
overdueBy = getFriendlyDuration(appliedSLA.FirstResponseBreachedAt.Time)
case MetricsResolution:
dueIn = getFriendlyDuration(appliedSLA.ResolutionDeadlineAt)
overdueBy = getFriendlyDuration(appliedSLA.ResolutionBreachedAt.Time)
default:
m.lo.Error("unknown metric type", "metric", scheduledNotification.Metric)
return fmt.Errorf("unknown metric type: %s", scheduledNotification.Metric)
}
// Set the metric label.
var metricLabel string
if label, ok := metricLabels[scheduledNotification.Metric]; ok {
metricLabel = label
}
// Render the email template.
content, subject, err := m.template.RenderStoredEmailTemplate(tmpl,
map[string]any{
"SLA": map[string]any{
"DueIn": dueIn,
"OverdueBy": overdueBy,
"Metric": metricLabel,
},
"Conversation": map[string]any{
"ReferenceNumber": appliedSLA.ConversationReferenceNumber,
"Subject": appliedSLA.ConversationSubject,
"Priority": "",
"UUID": appliedSLA.ConversationUUID,
},
"Agent": map[string]any{
"FirstName": agent.FirstName,
"LastName": agent.LastName,
"FullName": agent.FullName(),
"Email": agent.Email,
},
"Recipient": map[string]any{
"FirstName": agent.FirstName,
"LastName": agent.LastName,
"FullName": agent.FullName(),
"Email": agent.Email,
},
})
if err != nil {
m.lo.Error("error rendering email template", "template", template.TmplConversationAssigned, "scheduled_notification_id", scheduledNotification.ID, "error", err)
continue
}
// Enqueue email notification.
if err := m.notifier.Send(notifier.Message{
RecipientEmails: []string{
agent.Email.String,
},
Subject: subject,
Content: content,
Provider: notifier.ProviderEmail,
}); err != nil {
m.lo.Error("error sending email notification", "error", err)
}
// Set the notification as processed.
if _, err := m.q.MarkNotificationProcessed.Exec(scheduledNotification.ID); err != nil {
m.lo.Error("error marking notification as processed", "error", err)
}
}
return nil
}
// Close closes the SLA evaluation loop by stopping the worker pool.
func (m *Manager) Close() error {
m.wg.Wait()
return nil
}
// evaluatePendingSLAs fetches unbreached SLAs and evaluates them.
// Here evaluation means checking if the SLA deadlines have been met or breached and updating timestamps accordingly.
// getBusinessHoursAndTimezone returns the business hours ID and timezone for a team, falling back to app settings i.e. default helpdesk settings.
func (m *Manager) getBusinessHoursAndTimezone(assignedTeamID int) (bmodels.BusinessHours, string, error) {
var (
businessHrsID int
timezone string
bh bmodels.BusinessHours
)
// Fetch from team if assignedTeamID is provided.
if assignedTeamID != 0 {
team, err := m.teamStore.Get(assignedTeamID)
if err == nil {
businessHrsID = team.BusinessHoursID.Int
timezone = team.Timezone
}
}
// Else fetch from app settings, this is System default.
if businessHrsID == 0 || timezone == "" {
settingsJ, err := m.appSettingsStore.GetByPrefix("app")
if err != nil {
return bh, "", err
}
var out map[string]interface{}
if err := json.Unmarshal([]byte(settingsJ), &out); err != nil {
return bh, "", fmt.Errorf("parsing settings: %v", err)
}
businessHrsIDStr, _ := out["app.business_hours_id"].(string)
businessHrsID, _ = strconv.Atoi(businessHrsIDStr)
timezone, _ = out["app.timezone"].(string)
}
// If still not found, return error.
if businessHrsID == 0 || timezone == "" {
return bh, "", fmt.Errorf("business hours or timezone not configured")
}
bh, err := m.businessHrsStore.Get(businessHrsID)
if err != nil {
if err == businesshours.ErrBusinessHoursNotFound {
m.lo.Warn("business hours not found", "team_id", assignedTeamID)
return bh, "", fmt.Errorf("business hours not found")
}
m.lo.Error("error fetching business hours for SLA", "error", err)
return bh, "", err
}
return bh, timezone, nil
}
// createNotificationSchedule creates a notification schedule in database for the applied SLA.
func (m *Manager) createNotificationSchedule(notifications models.SlaNotifications, appliedSLAID int, deadlines Deadlines, breaches Breaches) {
scheduleNotification := func(sendAt time.Time, metric, notifType string, recipients []string) {
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 {
m.lo.Error("error inserting scheduled SLA notification", "error", err)
}
}
// Insert scheduled entries for each notification.
for _, notif := range notifications {
var (
delayDur time.Duration
err error
)
// No delay for immediate notifications.
if notif.TimeDelayType == "immediately" {
delayDur = 0
} else {
delayDur, err = time.ParseDuration(notif.TimeDelay)
if err != nil {
m.lo.Error("error parsing sla notification delay", "error", err)
continue
}
}
if notif.Type == NotificationTypeWarning {
if !deadlines.FirstResponse.IsZero() {
scheduleNotification(deadlines.FirstResponse.Add(-delayDur), MetricFirstResponse, notif.Type, notif.Recipients)
}
if !deadlines.Resolution.IsZero() {
scheduleNotification(deadlines.Resolution.Add(-delayDur), MetricsResolution, 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)
}
}
}
}
// evaluatePendingSLAs fetches pending SLAs and evaluates them, pending SLAs are applied SLAs that have not breached or met yet.
func (m *Manager) evaluatePendingSLAs(ctx context.Context) error {
var pendingSLAs []models.AppliedSLA
if err := m.q.GetPendingSLAs.SelectContext(ctx, &pendingSLAs); err != nil {
@@ -307,30 +578,30 @@ func (m *Manager) evaluatePendingSLAs(ctx context.Context) error {
// evaluateSLA evaluates an SLA policy on an applied SLA.
func (m *Manager) evaluateSLA(sla models.AppliedSLA) error {
m.lo.Debug("evaluating SLA", "conversation_id", sla.ConversationID, "applied_sla_id", sla.ID)
checkDeadline := func(deadline time.Time, metAt null.Time, slaType string) error {
checkDeadline := func(deadline time.Time, metAt null.Time, metric string) error {
if deadline.IsZero() {
m.lo.Debug("deadline zero, skipping checking the deadline")
m.lo.Warn("deadline zero, skipping checking the deadline")
return nil
}
now := time.Now()
if !metAt.Valid && now.After(deadline) {
m.lo.Debug("SLA breached as current time is after deadline", "deadline", deadline, "now", now, "sla_type", slaType)
if _, err := m.q.UpdateBreach.Exec(sla.ID, slaType); err != nil {
return fmt.Errorf("updating SLA breach: %w", err)
m.lo.Debug("SLA breached as current time is after deadline", "deadline", deadline, "now", now, "metric", metric)
if err := m.updateBreachAt(sla.ID, sla.SLAPolicyID, metric); err != nil {
return fmt.Errorf("updating SLA breach timestamp: %w", err)
}
return nil
}
if metAt.Valid {
if metAt.Time.After(deadline) {
m.lo.Debug("SLA breached as met_at is after deadline", "deadline", deadline, "met_at", metAt.Time, "sla_type", slaType)
if _, err := m.q.UpdateBreach.Exec(sla.ID, slaType); err != nil {
m.lo.Debug("SLA breached as met_at is after deadline", "deadline", deadline, "met_at", metAt.Time, "metric", metric)
if err := m.updateBreachAt(sla.ID, sla.SLAPolicyID, metric); err != nil {
return fmt.Errorf("updating SLA breach: %w", err)
}
} else {
m.lo.Debug("SLA type met", "deadline", deadline, "met_at", metAt.Time, "sla_type", slaType)
if _, err := m.q.UpdateMet.Exec(sla.ID, slaType); err != nil {
m.lo.Debug("SLA type met", "deadline", deadline, "met_at", metAt.Time, "metric", metric)
if _, err := m.q.UpdateMet.Exec(sla.ID, metric); err != nil {
return fmt.Errorf("updating SLA met: %w", err)
}
}
@@ -340,16 +611,16 @@ func (m *Manager) evaluateSLA(sla models.AppliedSLA) error {
// If first response is not breached and not met, check the deadline and set them.
if !sla.FirstResponseBreachedAt.Valid && !sla.FirstResponseMetAt.Valid {
m.lo.Debug("checking deadline", "deadline", sla.FirstResponseDeadlineAt, "met_at", sla.ConversationFirstResponseAt.Time, "sla_type", SLATypeFirstResponse)
if err := checkDeadline(sla.FirstResponseDeadlineAt, sla.ConversationFirstResponseAt, SLATypeFirstResponse); err != nil {
m.lo.Debug("checking deadline", "deadline", sla.FirstResponseDeadlineAt, "met_at", sla.ConversationFirstResponseAt.Time, "metric", MetricFirstResponse)
if err := checkDeadline(sla.FirstResponseDeadlineAt, sla.ConversationFirstResponseAt, MetricFirstResponse); err != nil {
return err
}
}
// 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, "sla_type", SLATypeResolution)
if err := checkDeadline(sla.ResolutionDeadlineAt, sla.ConversationResolvedAt, SLATypeResolution); err != nil {
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 {
return err
}
}
@@ -366,3 +637,32 @@ func (m *Manager) evaluateSLA(sla models.AppliedSLA) error {
return nil
}
// updateBreachAt updates the breach timestamp for an SLA.
func (m *Manager) updateBreachAt(appliedSLAID, slaPolicyID int, metric string) error {
if _, err := m.q.UpdateBreach.Exec(appliedSLAID, metric); err != nil {
return err
}
// Schedule notification for the breach if there are any.
sla, err := m.Get(slaPolicyID)
if err != nil {
m.lo.Error("error fetching SLA for scheduling breach notification", "error", err)
return err
}
var firstResponse, resolution time.Time
if metric == MetricFirstResponse {
firstResponse = time.Now()
} else if metric == MetricsResolution {
resolution = time.Now()
}
// Create notification schedule.
m.createNotificationSchedule(sla.Notifications, appliedSLAID, Deadlines{}, Breaches{
FirstResponse: firstResponse,
Resolution: resolution,
})
return nil
}

View File

@@ -160,3 +160,25 @@ func RemoveItemByValue(slice []string, value string) []string {
}
return result
}
// FormatDuration formats a duration as a string.
func FormatDuration(d time.Duration, includeSeconds bool) string {
d = d.Round(time.Second)
h := int64(d.Hours())
d -= time.Duration(h) * time.Hour
m := int64(d.Minutes())
d -= time.Duration(m) * time.Minute
s := int64(d.Seconds())
var parts []string
if h > 0 {
parts = append(parts, fmt.Sprintf("%d hours", h))
}
if m >= 0 {
parts = append(parts, fmt.Sprintf("%d minutes", m))
}
if s > 0 && includeSeconds {
parts = append(parts, fmt.Sprintf("%d seconds", s))
}
return strings.Join(parts, " ")
}

View File

@@ -12,6 +12,8 @@ import (
const (
// Built-in templates names stored in the database.
TmplConversationAssigned = "Conversation assigned"
TmplSLABreachWarning = "SLA breach warning"
TmplSLABreached = "SLA breached"
// Built-in templates fetched from memory stored in `static` directory.
TmplResetPassword = "reset-password"

View File

@@ -15,6 +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');
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_notification_type" CASCADE; CREATE TYPE "sla_notification_type" AS ENUM ('warning', 'breach');
-- Sequence to generate reference number for conversations.
DROP SEQUENCE IF EXISTS conversation_reference_number_sequence; CREATE SEQUENCE conversation_reference_number_sequence START 100;
@@ -36,6 +38,7 @@ CREATE TABLE sla_policies (
description TEXT NULL,
first_response_time TEXT NOT NULL,
resolution_time TEXT NOT 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)
);
@@ -448,6 +451,21 @@ 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 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,
metric sla_metric NOT NULL,
notification_type sla_notification_type NOT NULL,
recipients TEXT[] NOT NULL,
send_at TIMESTAMPTZ NOT NULL,
processed_at TIMESTAMPTZ
);
CREATE INDEX index_scheduled_sla_notifications_on_send_at ON scheduled_sla_notifications(send_at);
CREATE INDEX index_scheduled_sla_notifications_on_processed_at ON scheduled_sla_notifications(processed_at);
DROP TABLE IF EXISTS ai_providers CASCADE;
CREATE TABLE ai_providers (
id SERIAL PRIMARY KEY,
@@ -552,8 +570,6 @@ VALUES
INSERT INTO templates
("type", body, is_default, "name", subject, is_builtin)
VALUES('email_notification'::template_type, '
<p>Hi {{ .Agent.FirstName }},</p>
<p>A new conversation has been assigned to you:</p>
<div>
@@ -571,3 +587,66 @@ VALUES('email_notification'::template_type, '
</div>
', false, 'Conversation assigned', 'New conversation assigned to you', true);
INSERT INTO templates
("type", body, is_default, "name", subject, is_builtin)
VALUES (
'email_notification'::template_type,
'
<p>This is a notification that the SLA for conversation {{ .Conversation.ReferenceNumber }} is approaching the SLA deadline for {{ .SLA.Metric }}.</p>
<p>
Details:<br>
- Conversation reference number: {{ .Conversation.ReferenceNumber }}<br>
- Metric: {{ .SLA.Metric }}<br>
- Due in: {{ .SLA.DueIn }}
</p>
<p>
<a href="{{ RootURL }}/inboxes/assigned/conversation/{{ .Conversation.UUID }}">View Conversation</a>
</p>
<p>
Best regards,<br>
Libredesk
</p>
',
false,
'SLA breach warning',
'SLA Alert: Conversation {{ .Conversation.ReferenceNumber }} is approaching SLA deadline for {{ .SLA.Metric }}',
true
);
INSERT INTO templates
("type", body, is_default, "name", subject, is_builtin)
VALUES (
'email_notification'::template_type,
'
<p>This is an urgent alert that the SLA for conversation {{ .Conversation.ReferenceNumber }} has been breached for {{ .SLA.Metric }}. Please take immediate action.</p>
<p>
Details:<br>
- Conversation reference number: {{ .Conversation.ReferenceNumber }}<br>
- Metric: {{ .SLA.Metric }}<br>
- Overdue by: {{ .SLA.OverdueBy }}
</p>
<p>
<a href="{{ RootURL }}/inboxes/assigned/conversation/{{ .Conversation.UUID }}">View Conversation</a>
</p>
<p>
Best regards,<br>
Libredesk
</p>
',
false,
'SLA breached',
'Urgent: SLA Breach for Conversation {{ .Conversation.ReferenceNumber }} for {{ .SLA.Metric }}',
true
);