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 +);