diff --git a/cmd/handlers.go b/cmd/handlers.go
index 878da0b..d729c6f 100644
--- a/cmd/handlers.go
+++ b/cmd/handlers.go
@@ -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.
diff --git a/cmd/init.go b/cmd/init.go
index d3eddcd..a72c6bc 100644
--- a/cmd/init.go
+++ b/cmd/init.go
@@ -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"),
})
diff --git a/cmd/main.go b/cmd/main.go
index a9d6282..0143be6 100644
--- a/cmd/main.go
+++ b/cmd/main.go
@@ -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)
diff --git a/cmd/sla.go b/cmd/sla.go
index c33064c..6c6bf88 100644
--- a/cmd/sla.go
+++ b/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
}
diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js
index 2d21966..93e0dd6 100644
--- a/frontend/src/api/index.js
+++ b/frontend/src/api/index.js
@@ -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, {
diff --git a/frontend/src/features/admin/sla/formSchema.js b/frontend/src/features/admin/sla/formSchema.js
index 2ac4829..7a83528 100644
--- a/frontend/src/features/admin/sla/formSchema.js
+++ b/frontend/src/features/admin/sla/formSchema.js
@@ -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([])
})
diff --git a/frontend/src/views/admin/sla/CreateEditSLA.vue b/frontend/src/views/admin/sla/CreateEditSLA.vue
index 48ca651..fe10695 100644
--- a/frontend/src/views/admin/sla/CreateEditSLA.vue
+++ b/frontend/src/views/admin/sla/CreateEditSLA.vue
@@ -1,14 +1,18 @@
-
-
-
-
-
+
+
+
+
+
\ No newline at end of file
+
diff --git a/internal/sla/models/models.go b/internal/sla/models/models.go
index 70763ea..97171d3 100644
--- a/internal/sla/models/models.go
+++ b/internal/sla/models/models.go
@@ -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"`
}
diff --git a/internal/sla/queries.sql b/internal/sla/queries.sql
index b02a00a..815c96e 100644
--- a/internal/sla/queries.sql
+++ b/internal/sla/queries.sql
@@ -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;
\ No newline at end of file
diff --git a/internal/sla/sla.go b/internal/sla/sla.go
index 437913e..991d0b7 100644
--- a/internal/sla/sla.go
+++ b/internal/sla/sla.go
@@ -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
+}
diff --git a/internal/stringutil/stringutil.go b/internal/stringutil/stringutil.go
index 05255a8..e5cb69c 100644
--- a/internal/stringutil/stringutil.go
+++ b/internal/stringutil/stringutil.go
@@ -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, " ")
+}
diff --git a/internal/template/render.go b/internal/template/render.go
index 18aea9b..6a6abdd 100644
--- a/internal/template/render.go
+++ b/internal/template/render.go
@@ -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"
diff --git a/schema.sql b/schema.sql
index 8110bdc..febfbf0 100644
--- a/schema.sql
+++ b/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, '
-
Hi {{ .Agent.FirstName }},
-
A new conversation has been assigned to you:
@@ -570,4 +586,67 @@ VALUES('email_notification'::template_type, '
Libredesk
-', false, 'Conversation assigned', 'New conversation assigned to you', true);
\ No newline at end of file
+', 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,
+ '
+
+This is a notification that the SLA for conversation {{ .Conversation.ReferenceNumber }} is approaching the SLA deadline for {{ .SLA.Metric }}.
+
+
+ Details:
+ - Conversation reference number: {{ .Conversation.ReferenceNumber }}
+ - Metric: {{ .SLA.Metric }}
+ - Due in: {{ .SLA.DueIn }}
+
+
+
+ View Conversation
+
+
+
+
+ Best regards,
+ Libredesk
+
+
+',
+ 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,
+ '
+This is an urgent alert that the SLA for conversation {{ .Conversation.ReferenceNumber }} has been breached for {{ .SLA.Metric }}. Please take immediate action.
+
+
+ Details:
+ - Conversation reference number: {{ .Conversation.ReferenceNumber }}
+ - Metric: {{ .SLA.Metric }}
+ - Overdue by: {{ .SLA.OverdueBy }}
+
+
+
+ View Conversation
+
+
+
+
+ Best regards,
+ Libredesk
+
+
+',
+ false,
+ 'SLA breached',
+ 'Urgent: SLA Breach for Conversation {{ .Conversation.ReferenceNumber }} for {{ .SLA.Metric }}',
+ true
+);