Feat: Add SLAs per team

- Feat: Provide the ability to change SLAs for conversations; this will start a new deadline calculation from the time the SLA is set.
This commit is contained in:
Abhinav Raut
2025-01-24 04:34:51 +05:30
parent 8ded2692a3
commit 3c71ab35ff
24 changed files with 424 additions and 458 deletions

View File

@@ -8,11 +8,16 @@ import (
"github.com/abhinavxd/libredesk/internal/business_hours/models"
)
var (
ErrInvalidSLADuration = fmt.Errorf("invalid SLA duration")
ErrMaxIterations = fmt.Errorf("sla: exceeded maximum iterations - check configuration")
)
// CalculateDeadline computes the SLA deadline from a start time and SLA duration in minutes
// considering the provided holidays, working hours, and time zone.
func (m *Manager) CalculateDeadline(start time.Time, slaMinutes int, businessHours models.BusinessHours, timeZone string) (time.Time, error) {
if slaMinutes <= 0 {
return time.Time{}, fmt.Errorf("SLA duration must be positive")
return time.Time{}, ErrInvalidSLADuration
}
// If business is always open, return the deadline as the start time plus the SLA duration.
@@ -34,13 +39,13 @@ func (m *Manager) CalculateDeadline(start time.Time, slaMinutes int, businessHou
// Unmarshal working hours.
var workingHours map[string]models.WorkingHours
if err := json.Unmarshal(businessHours.Hours, &workingHours); err != nil {
return time.Time{}, fmt.Errorf("could not unmarshal working hours: %v", err)
return time.Time{}, fmt.Errorf("could not unmarshal working hours for SLA deadline calcuation: %v", err)
}
// Unmarshal holidays.
var holidays = []models.Holiday{}
if err := json.Unmarshal(businessHours.Holidays, &holidays); err != nil {
return time.Time{}, fmt.Errorf("could not unmarshal holidays: %v", err)
return time.Time{}, fmt.Errorf("could not unmarshal holidays for SLA deadline calcuation: %v", err)
}
// Create a map of holidays.
@@ -53,7 +58,7 @@ func (m *Manager) CalculateDeadline(start time.Time, slaMinutes int, businessHou
for remainingMinutes > 0 {
iterations++
if iterations > maxIterations {
return time.Time{}, fmt.Errorf("sla: exceeded maximum iterations - check configuration")
return time.Time{}, ErrMaxIterations
}
// Skip holidays.

View File

@@ -1,4 +1,3 @@
// package models contains the model definitions for the SLA package.
package models
import (
@@ -7,7 +6,7 @@ import (
"github.com/volatiletech/null/v9"
)
// SLAPolicy represents an SLA policy.
// 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"`
@@ -15,23 +14,20 @@ type SLAPolicy struct {
Name string `db:"name" json:"name"`
Description string `db:"description" json:"description"`
FirstResponseTime string `db:"first_response_time" json:"first_response_time"`
ResolutionTime string `db:"resolution_time" json:"resolution_time"`
EveryResponseTime string `db:"every_response_time" json:"every_response_time"`
ResolutionTime string `db:"resolution_time" json:"resolution_time"`
}
// ConversationSLA represents an SLA policy applied to a conversation.
type ConversationSLA struct {
ID int `db:"id"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
ConversationID int `db:"conversation_id"`
ConversationCreatedAt time.Time `db:"conversation_created_at"`
ConversationFirstReplyAt null.Time `db:"conversation_first_reply_at"`
ConversationLastMessageAt null.Time `db:"conversation_last_message_at"`
ConversationResolvedAt null.Time `db:"conversation_resolved_at"`
ConversationAssignedTeamID null.Int `db:"conversation_assigned_team_id"`
SLAPolicyID int `db:"sla_policy_id"`
SLAType string `db:"sla_type"`
DueAt null.Time `db:"due_at"`
BreachedAt null.Time `db:"breached_at"`
// AppliedSLA represents an SLA policy applied to a conversation with its deadlines and breach status
type AppliedSLA struct {
ID int `db:"id"`
CreatedAt time.Time `db:"created_at"`
ConversationID int `db:"conversation_id"`
SLAPolicyID int `db:"sla_policy_id"`
FirstResponseDeadlineAt time.Time `db:"first_response_deadline_at"`
ResolutionDeadlineAt time.Time `db:"resolution_deadline_at"`
FirstResponseBreachedAt null.Time `db:"first_response_breached_at"`
ResolutionBreachedAt null.Time `db:"resolution_breached_at"`
FirstResponseAt null.Time `db:"first_response_at"`
ResolvedAt null.Time `db:"resolved_at"`
}

View File

@@ -1,105 +1,71 @@
-- name: get-sla-policy
SELECT id,
created_at,
updated_at,
"name",
description,
first_response_time,
resolution_time
FROM sla_policies
WHERE id = $1;
SELECT * FROM sla_policies WHERE id = $1;
-- name: get-all-sla-policies
SELECT id,
created_at,
updated_at,
"name",
description,
first_response_time,
resolution_time
FROM sla_policies
ORDER BY updated_at DESC;
SELECT * 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,
description,
first_response_time,
resolution_time
) VALUES ($1, $2, $3, $4);
-- name: delete-sla-policy
DELETE FROM sla_policies
WHERE id = $1;
DELETE FROM sla_policies WHERE id = $1;
-- name: update-sla-policy
UPDATE sla_policies
SET "name" = $2,
description = $3,
first_response_time = $4,
resolution_time = $5,
updated_at = NOW()
UPDATE sla_policies SET
name = $2,
description = $3,
first_response_time = $4,
resolution_time = $5,
updated_at = NOW()
WHERE id = $1;
-- name: apply-sla-policy
INSERT INTO applied_slas (
status,
conversation_id,
sla_policy_id
)
VALUES ($1, $2, $3);
-- name: get-unbreached-slas
-- TODO: name this better.
SELECT
cs.id,
cs.created_at,
cs.updated_at,
cs.sla_policy_id,
cs.sla_type,
cs.breached_at,
cs.due_at,
c.created_at as conversation_created_at,
c.first_reply_at as conversation_first_reply_at,
c.last_message_at as conversation_last_message_at,
c.resolved_at as conversation_resolved_at,
c.assigned_team_id as conversation_assigned_team_id
FROM conversation_slas cs
INNER JOIN conversations c ON cs.conversation_id = c.id AND c.sla_policy_id = cs.sla_policy_id
WHERE cs.breached_at is NULL AND cs.met_at is NULL
-- name: update-breached-at
UPDATE conversation_slas
SET breached_at = NOW(), updated_at = NOW()
WHERE id = $1;
-- name: update-due-at
WITH updated_slas AS (
UPDATE conversation_slas
SET due_at = $2,
updated_at = NOW()
WHERE id = $1
RETURNING conversation_id
-- 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
)
-- Also set the earliest due_at in the conversation
UPDATE conversations
SET next_sla_deadline_at = $2
WHERE id IN (SELECT conversation_id FROM updated_slas)
AND (next_sla_deadline_at IS NULL OR next_sla_deadline_at > $2);
UPDATE conversations
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);
-- name: update-met-at
UPDATE conversation_slas
SET met_at = $2, updated_at = NOW()
-- name: get-pending-slas
SELECT a.id, a.first_response_deadline_at, c.first_reply_at as first_response_at,
a.resolution_deadline_at, c.resolved_at as resolved_at
FROM applied_slas a
JOIN conversations c ON a.conversation_id = c.id and c.sla_policy_id = a.sla_policy_id
WHERE (first_response_breached_at IS NULL AND first_response_met_at IS NULL)
OR (resolution_breached_at IS NULL AND resolution_met_at IS NULL);
-- name: update-breach
UPDATE applied_slas SET
first_response_breached_at = CASE WHEN $2 = 'first_response' THEN NOW() ELSE first_response_breached_at END,
resolution_breached_at = CASE WHEN $2 = 'resolution' THEN NOW() ELSE resolution_breached_at END,
updated_at = NOW()
WHERE id = $1;
-- name: insert-conversation-sla
WITH inserted AS (
INSERT INTO conversation_slas (conversation_id, sla_policy_id, sla_type)
VALUES ($1, $2, $3)
RETURNING conversation_id, sla_policy_id
)
UPDATE conversations
SET sla_policy_id = inserted.sla_policy_id
FROM inserted
WHERE conversations.id = inserted.conversation_id;
-- name: update-met
UPDATE applied_slas SET
first_response_met_at = CASE WHEN $2 = 'first_response' THEN NOW() ELSE first_response_met_at END,
resolution_met_at = CASE WHEN $2 = 'resolution' THEN NOW() ELSE resolution_met_at END,
updated_at = NOW()
WHERE id = $1;
-- name: get-latest-sla-deadlines
SELECT first_response_deadline_at, resolution_deadline_at
FROM applied_slas
WHERE conversation_id = $1
ORDER BY created_at DESC LIMIT 1;

View File

@@ -1,53 +1,58 @@
// Package sla implements service-level agreement (SLA) calculations for conversations.
package sla
import (
"context"
"database/sql"
"embed"
"encoding/json"
"fmt"
"strconv"
"sync"
"time"
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"
models "github.com/abhinavxd/libredesk/internal/sla/models"
tmodels "github.com/abhinavxd/libredesk/internal/team/models"
"github.com/abhinavxd/libredesk/internal/workerpool"
"github.com/jmoiron/sqlx"
"github.com/jmoiron/sqlx/types"
"github.com/volatiletech/null/v9"
"github.com/zerodha/logf"
)
var (
//go:embed queries.sql
efs embed.FS
slaGracePeriod = 5 * time.Minute
)
const (
SLATypeFirstResponse = "first_response"
SLATypeResolution = "resolution"
SLATypeEveryResponse = "every_response"
)
// Manager provides SLA management and calculations.
// Manager manages SLA policies and calculations.
type Manager struct {
q queries
lo *logf.Logger
pool *workerpool.Pool
teamStore teamStore
appSettingsStore appSettingsStore
businessHrsStore businessHrsStore
wg sync.WaitGroup
opts Opts
}
// Opts defines options for initializing Manager.
// Opts defines the options for creating SLA manager.
type Opts struct {
DB *sqlx.DB
Lo *logf.Logger
ScannerInterval time.Duration
DB *sqlx.DB
Lo *logf.Logger
}
// Deadlines holds the deadlines for an SLA policy.
type Deadlines struct {
FirstResponse time.Time
Resolution time.Time
}
type teamStore interface {
@@ -62,38 +67,30 @@ type businessHrsStore interface {
Get(id int) (bmodels.BusinessHours, error)
}
// queries holds prepared SQL statements.
// 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"`
GetUnbreachedSLAs *sqlx.Stmt `query:"get-unbreached-slas"`
UpdateBreachedAt *sqlx.Stmt `query:"update-breached-at"`
UpdateDueAt *sqlx.Stmt `query:"update-due-at"`
UpdateMetAt *sqlx.Stmt `query:"update-met-at"`
InsertConversationSLA *sqlx.Stmt `query:"insert-conversation-sla"`
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"`
GetLatestDeadlines *sqlx.Stmt `query:"get-latest-sla-deadlines"`
}
// New returns a new Manager.
func New(opts Opts, pool *workerpool.Pool, teamStore teamStore, appSettingsStore appSettingsStore, businessHrsStore businessHrsStore) (*Manager, error) {
// New creates a new SLA manager.
func New(opts Opts, teamStore teamStore, appSettingsStore appSettingsStore, businessHrsStore businessHrsStore) (*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,
pool: pool,
teamStore: teamStore,
appSettingsStore: appSettingsStore,
businessHrsStore: businessHrsStore,
opts: opts,
}, nil
return &Manager{q: q, lo: opts.Lo, teamStore: teamStore, appSettingsStore: appSettingsStore, businessHrsStore: businessHrsStore, opts: opts}, nil
}
// Get retrieves an SLA by its ID.
// 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 {
@@ -114,15 +111,15 @@ func (m *Manager) GetAll() ([]models.SLAPolicy, error) {
}
// Create adds a new SLA policy.
func (m *Manager) Create(name, description, firstResponseDuration, resolutionDuration string) error {
if _, err := m.q.InsertSLA.Exec(name, description, firstResponseDuration, resolutionDuration); err != nil {
func (m *Manager) Create(name, description string, firstResponseTime, resolutionTime string) error {
if _, err := m.q.InsertSLA.Exec(name, description, firstResponseTime, resolutionTime); err != nil {
m.lo.Error("error inserting SLA", "error", err)
return envelope.NewError(envelope.GeneralError, "Error creating SLA", nil)
}
return nil
}
// Delete removes an SLA policy by its ID.
// Delete removes an SLA policy.
func (m *Manager) Delete(id int) error {
if _, err := m.q.DeleteSLA.Exec(id); err != nil {
m.lo.Error("error deleting SLA", "error", err)
@@ -131,94 +128,43 @@ func (m *Manager) Delete(id int) error {
return nil
}
// Update modifies an SLA policy by its ID.
func (m *Manager) Update(id int, name, description, firstResponseDuration, resolutionDuration string) error {
if _, err := m.q.UpdateSLA.Exec(id, name, description, firstResponseDuration, resolutionDuration); err != 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
}
// ApplySLA associates an SLA policy with a conversation.
func (m *Manager) ApplySLA(conversationID, slaPolicyID int) (models.SLAPolicy, error) {
sla, err := m.Get(slaPolicyID)
if err != nil {
return sla, err
}
for _, t := range []string{SLATypeFirstResponse, SLATypeResolution} {
if t == SLATypeFirstResponse && sla.FirstResponseTime == "" {
continue
}
if t == SLATypeResolution && sla.ResolutionTime == "" {
continue
}
if _, err := m.q.InsertConversationSLA.Exec(conversationID, slaPolicyID, t); err != nil && !dbutil.IsUniqueViolationError(err) {
m.lo.Error("error applying SLA to conversation", "error", err)
return sla, envelope.NewError(envelope.GeneralError, "Error applying SLA to conversation", nil)
}
}
return sla, nil
}
// Run starts the SLA worker pool and periodically processes unbreached SLAs (blocking).
func (m *Manager) Run(ctx context.Context) {
ticker := time.NewTicker(m.opts.ScannerInterval)
defer ticker.Stop()
m.pool.Run()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := m.processUnbreachedSLAs(); err != nil {
m.lo.Error("error during SLA periodic check", "error", err)
}
}
}
}
// Close shuts down the SLA worker pool.
func (m *Manager) Close() error {
m.pool.Close()
return nil
}
// CalculateConversationDeadlines calculates deadlines for SLA policies attached to a conversation.
func (m *Manager) CalculateConversationDeadlines(conversationCreatedAt time.Time, assignedTeamID, slaPolicyID int) (time.Time, time.Time, error) {
// getBusinessHours returns the business hours ID and timezone for a team.
func (m *Manager) getBusinessHours(assignedTeamID int) (bmodels.BusinessHours, string, error) {
var (
businessHrsID, timezone = 0, ""
firstResponseDeadline, resolutionDeadline = time.Time{}, time.Time{}
businessHrsID int
timezone string
bh bmodels.BusinessHours
)
// Fetch SLA policy.
slaPolicy, err := m.Get(slaPolicyID)
if err != nil {
return firstResponseDeadline, resolutionDeadline, err
}
// First fetch business hours and timezone from assigned team if available.
// Fetch from team if assigned.
if assignedTeamID != 0 {
team, err := m.teamStore.Get(assignedTeamID)
if err != nil {
return firstResponseDeadline, resolutionDeadline, err
return bh, "", err
}
businessHrsID = team.BusinessHoursID.Int
timezone = team.Timezone
}
// If not found in team, fetch from app settings.
// Else fetch from app settings, this is System default.
if businessHrsID == 0 || timezone == "" {
settingsJ, err := m.appSettingsStore.GetByPrefix("app")
if err != nil {
return firstResponseDeadline, resolutionDeadline, err
return bh, "", err
}
var out map[string]interface{}
if err := json.Unmarshal([]byte(settingsJ), &out); err != nil {
m.lo.Error("error parsing settings", "error", err)
return firstResponseDeadline, resolutionDeadline, envelope.NewError(envelope.GeneralError, "Error parsing settings", nil)
return bh, "", fmt.Errorf("parsing settings: %v", err)
}
businessHrsIDStr, _ := out["app.business_hours_id"].(string)
@@ -226,129 +172,170 @@ func (m *Manager) CalculateConversationDeadlines(conversationCreatedAt time.Time
timezone, _ = out["app.timezone"].(string)
}
// Not set, skip SLA calculation.
// If still not found, return error.
if businessHrsID == 0 || timezone == "" {
m.lo.Warn("default business hours or timezone not set, skipping SLA calculation")
return firstResponseDeadline, resolutionDeadline, nil
return bh, "", fmt.Errorf("business hours or timezone not configured")
}
bh, err := m.businessHrsStore.Get(businessHrsID)
if err != nil {
m.lo.Error("error fetching business hours", "error", err)
return firstResponseDeadline, resolutionDeadline, err
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) {
var deadlines Deadlines
businessHrs, timezone, err := m.getBusinessHours(assignedTeamID)
if err != nil {
return deadlines, err
}
m.lo.Info("calculating deadlines", "business_hours", businessHrs.Hours, "timezone", timezone, "always_open", businessHrs.IsAlwaysOpen)
sla, err := m.Get(slaPolicyID)
if err != nil {
return deadlines, err
}
calculateDeadline := func(durationStr string) (time.Time, error) {
if durationStr == "" {
return time.Time{}, nil
}
dur, parseErr := time.ParseDuration(durationStr)
if parseErr != nil {
return time.Time{}, fmt.Errorf("parsing duration: %v", parseErr)
dur, err := time.ParseDuration(durationStr)
if err != nil {
return time.Time{}, fmt.Errorf("parsing SLA duration: %v", err)
}
deadline, err := m.CalculateDeadline(
conversationCreatedAt,
int(dur.Minutes()),
bh,
timezone,
)
deadline, err := m.CalculateDeadline(startTime, int(dur.Minutes()), businessHrs, timezone)
if err != nil {
return time.Time{}, err
}
return deadline.Add(slaGracePeriod), nil
return deadline, nil
}
firstResponseDeadline, err = calculateDeadline(slaPolicy.FirstResponseTime)
if err != nil {
return firstResponseDeadline, resolutionDeadline, err
if deadlines.FirstResponse, err = calculateDeadline(sla.FirstResponseTime); err != nil {
return deadlines, err
}
resolutionDeadline, err = calculateDeadline(slaPolicy.ResolutionTime)
if err != nil {
return firstResponseDeadline, resolutionDeadline, err
if deadlines.Resolution, err = calculateDeadline(sla.ResolutionTime); err != nil {
return deadlines, err
}
return firstResponseDeadline, resolutionDeadline, nil
return deadlines, nil
}
// processUnbreachedSLAs fetches unbreached SLAs and pushes them to the worker pool for processing.
func (m *Manager) processUnbreachedSLAs() error {
var unbreachedSLAs []models.ConversationSLA
if err := m.q.GetUnbreachedSLAs.Select(&unbreachedSLAs); err != nil {
m.lo.Error("error fetching unbreached SLAs", "error", err)
// ApplySLA applies an SLA policy to a conversation.
func (m *Manager) ApplySLA(conversationID, assignedTeamID, slaPolicyID int) (models.SLAPolicy, error) {
var sla models.SLAPolicy
deadlines, err := m.CalculateDeadlines(time.Now(), slaPolicyID, assignedTeamID)
if err != nil {
return sla, err
}
if _, err := m.q.ApplySLA.Exec(
conversationID,
slaPolicyID,
deadlines.FirstResponse,
deadlines.Resolution,
); 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
}
return sla, nil
}
// GetLatestDeadlines returns the latest deadlines for a conversation.
func (m *Manager) GetLatestDeadlines(conversationID int) (time.Time, time.Time, error) {
var first, resolution time.Time
err := m.q.GetLatestDeadlines.QueryRow(conversationID).Scan(&first, &resolution)
if err == sql.ErrNoRows {
return first, resolution, nil
}
return first, resolution, err
}
// Run starts the SLA evaluation loop and evaluates pending SLAs.
func (m *Manager) Run(ctx context.Context) {
m.wg.Add(1)
defer m.wg.Done()
ticker := time.NewTicker(2 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := m.evaluatePendingSLAs(ctx); err != nil {
m.lo.Error("error processing pending SLAs", "error", err)
}
}
}
}
// 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.
func (m *Manager) evaluatePendingSLAs(ctx context.Context) error {
var pendingSLAs []models.AppliedSLA
if err := m.q.GetPendingSLAs.SelectContext(ctx, &pendingSLAs); err != nil {
m.lo.Error("error fetching pending SLAs", "error", err)
return err
}
m.lo.Debug("processing unbreached SLAs", "count", len(unbreachedSLAs))
for _, u := range unbreachedSLAs {
slaData := u
m.pool.Push(func() {
if err := m.evaluateSLA(slaData); err != nil {
m.lo.Error("error processing SLA", "error", err)
m.lo.Info("evaluating pending SLAs", "count", len(pendingSLAs))
for _, sla := range pendingSLAs {
select {
case <-ctx.Done():
return ctx.Err()
default:
if err := m.evaluateSLA(sla); err != nil {
m.lo.Error("error evaluating SLA", "error", err)
}
})
}
}
return nil
}
// evaluateSLA checks if an SLA has been breached or met and updates the database accordingly.
func (m *Manager) evaluateSLA(cSLA models.ConversationSLA) error {
var deadline, compareTime time.Time
// Calculate deadlines using the `created_at` which is the time SLA was applied to the conversation.
// This will take care of the case where SLA is changed for a conversation.
m.lo.Info("calculating SLA deadlines", "start_time", cSLA.CreatedAt, "conversation_id", cSLA.ConversationID, "sla_policy_id", cSLA.SLAPolicyID)
firstResponseDeadline, resolutionDeadline, err := m.CalculateConversationDeadlines(cSLA.CreatedAt, cSLA.ConversationAssignedTeamID.Int, cSLA.SLAPolicyID)
if err != nil {
return err
}
switch cSLA.SLAType {
case SLATypeFirstResponse:
deadline = firstResponseDeadline
compareTime = cSLA.ConversationFirstReplyAt.Time
case SLATypeResolution:
deadline = resolutionDeadline
compareTime = cSLA.ConversationResolvedAt.Time
default:
return fmt.Errorf("unknown SLA type: %s", cSLA.SLAType)
}
if deadline.IsZero() {
m.lo.Warn("could not calculate SLA deadline", "conversation_id", cSLA.ConversationID, "sla_policy_id", cSLA.SLAPolicyID)
// evaluateSLA evaluates an SLA policy on an applied SLA.
func (m *Manager) evaluateSLA(sla models.AppliedSLA) error {
now := time.Now()
checkDeadline := func(deadline time.Time, metAt null.Time, slaType string) error {
if deadline.IsZero() {
return nil
}
if !metAt.Valid && now.After(deadline) {
_, err := m.q.UpdateBreach.Exec(sla.ID, slaType)
return err
}
if metAt.Valid {
if metAt.Time.After(deadline) {
_, err := m.q.UpdateBreach.Exec(sla.ID, slaType)
return err
}
_, err := m.q.UpdateMet.Exec(sla.ID, slaType)
return err
}
return nil
}
// Save deadline in DB.
if _, err := m.q.UpdateDueAt.Exec(cSLA.ID, deadline); err != nil {
m.lo.Error("error updating SLA due_at", "error", err)
return fmt.Errorf("updating SLA due_at: %v", err)
if err := checkDeadline(sla.FirstResponseDeadlineAt, sla.FirstResponseAt, SLATypeFirstResponse); err != nil {
return err
}
if !compareTime.IsZero() {
if compareTime.After(deadline) {
return m.markSLABreached(cSLA.ID)
}
return m.markSLAMet(cSLA.ID, compareTime)
}
if time.Now().After(deadline) {
return m.markSLABreached(cSLA.ID)
}
return nil
}
// markSLABreached updates the breach time for a conversation SLA.
func (m *Manager) markSLABreached(id int) error {
if _, err := m.q.UpdateBreachedAt.Exec(id); err != nil {
m.lo.Error("error updating SLA breach time", "error", err)
return fmt.Errorf("updating SLA breach time: %v", err)
}
return nil
}
// markSLAMet updates the met time for a conversation SLA.
func (m *Manager) markSLAMet(id int, t time.Time) error {
if _, err := m.q.UpdateMetAt.Exec(id, t); err != nil {
m.lo.Error("error updating SLA met time", "error", err)
return fmt.Errorf("updating SLA met time: %v", err)
if err := checkDeadline(sla.ResolutionDeadlineAt, sla.ResolvedAt, SLATypeResolution); err != nil {
return err
}
return nil
}