Files
libredesk/internal/sla/sla.go
Abhinav Raut 45541c221a fix: various bugs in SLA calculation
prevents multiple update queries unnecessarily on applied sla table.
clear next sla deadline in conversations properly when there's no deadline to be met.
uses the new status column in the applied sla table to determine if the sla is still active and has to be calculated again.
2025-03-12 02:45:17 +05:30

369 lines
12 KiB
Go

package sla
import (
"context"
"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/jmoiron/sqlx"
"github.com/jmoiron/sqlx/types"
"github.com/volatiletech/null/v9"
"github.com/zerodha/logf"
)
var (
//go:embed queries.sql
efs embed.FS
)
const (
SLATypeFirstResponse = "first_response"
SLATypeResolution = "resolution"
)
// Manager manages SLA policies and calculations.
type Manager struct {
q queries
lo *logf.Logger
teamStore teamStore
appSettingsStore appSettingsStore
businessHrsStore businessHrsStore
wg sync.WaitGroup
opts Opts
}
// Opts defines the options for creating SLA manager.
type Opts struct {
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 {
Get(id int) (tmodels.Team, error)
}
type appSettingsStore interface {
GetByPrefix(prefix string) (types.JSONText, error)
}
type businessHrsStore interface {
Get(id int) (bmodels.BusinessHours, error)
}
// 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"`
}
// 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, teamStore: teamStore, appSettingsStore: appSettingsStore, businessHrsStore: businessHrsStore, 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 {
m.lo.Error("error fetching SLA", "error", err)
return sla, envelope.NewError(envelope.GeneralError, "Error fetching SLA", nil)
}
return sla, nil
}
// GetAll fetches all SLA policies.
func (m *Manager) GetAll() ([]models.SLAPolicy, error) {
var slas = make([]models.SLAPolicy, 0)
if err := m.q.GetAllSLA.Select(&slas); err != nil {
m.lo.Error("error fetching SLAs", "error", err)
return nil, envelope.NewError(envelope.GeneralError, "Error fetching SLAs", nil)
}
return slas, nil
}
// 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 {
m.lo.Error("error inserting SLA", "error", err)
return envelope.NewError(envelope.GeneralError, "Error creating 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 {
m.lo.Error("error deleting SLA", "error", err)
return envelope.NewError(envelope.GeneralError, "Error deleting SLA", nil)
}
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) {
var deadlines Deadlines
businessHrs, timezone, err := m.getBusinessHoursAndTimezone(assignedTeamID)
if err != nil {
return deadlines, err
}
m.lo.Info("calculating deadlines", "timezone", timezone, "business_hours_always_open", businessHrs.IsAlwaysOpen, "business_hours", businessHrs.Hours)
sla, err := m.Get(slaPolicyID)
if err != nil {
return deadlines, err
}
// Helper function to calculate deadlines by parsing the duration string.
calculateDeadline := func(durationStr string) (time.Time, error) {
if durationStr == "" {
return time.Time{}, nil
}
dur, err := time.ParseDuration(durationStr)
if err != nil {
return time.Time{}, fmt.Errorf("parsing SLA duration: %v", err)
}
deadline, err := m.CalculateDeadline(startTime, int(dur.Minutes()), businessHrs, timezone)
if err != nil {
return time.Time{}, err
}
return deadline, nil
}
if deadlines.FirstResponse, err = calculateDeadline(sla.FirstResponseTime); err != nil {
return deadlines, err
}
if deadlines.Resolution, err = calculateDeadline(sla.ResolutionTime); err != nil {
return deadlines, err
}
return deadlines, nil
}
// ApplySLA applies an SLA policy to a conversation.
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)
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
}
// Run starts the SLA evaluation loop and evaluates pending SLAs.
func (m *Manager) Run(ctx context.Context, evalInterval time.Duration) {
ticker := time.NewTicker(evalInterval)
m.wg.Add(1)
defer func() {
m.wg.Done()
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.
// Here evaluation means checking if the SLA deadlines have been met or breached and updating timestamps accordingly.
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.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)
}
}
}
m.lo.Info("evaluated pending SLAs", "count", len(pendingSLAs))
return nil
}
// 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 {
if deadline.IsZero() {
m.lo.Debug("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)
}
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 {
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 {
return fmt.Errorf("updating SLA met: %w", err)
}
}
}
return nil
}
// 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 {
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 {
return err
}
}
// Update the conversation next SLA deadline.
if _, err := m.q.SetNextSLADeadline.Exec(sla.ConversationID); err != nil {
return fmt.Errorf("setting conversation next SLA deadline: %w", err)
}
// Update status of applied SLA.
if _, err := m.q.UpdateSLAStatus.Exec(sla.ID); err != nil {
return fmt.Errorf("updating applied SLA status: %w", err)
}
return nil
}