mirror of
https://github.com/abhinavxd/libredesk.git
synced 2025-11-02 04:53:41 +00:00
feat: configurable SLA alerts per SLA.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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"),
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
127
cmd/sla.go
127
cmd/sla.go
@@ -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
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
</script>
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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, ¬ifications); 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
|
||||
}
|
||||
|
||||
@@ -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, " ")
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
85
schema.sql
85
schema.sql
@@ -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>
|
||||
@@ -570,4 +586,67 @@ VALUES('email_notification'::template_type, '
|
||||
Libredesk
|
||||
</div>
|
||||
|
||||
', false, 'Conversation assigned', 'New conversation assigned to you', true);
|
||||
', 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
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user