mirror of
https://github.com/abhinavxd/libredesk.git
synced 2025-11-02 04:53:41 +00:00
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.
369 lines
12 KiB
Go
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
|
|
}
|