feat(sla): make sla breach events idempotent
This commit is contained in:
@@ -12,22 +12,22 @@ import (
|
||||
// Fields: first_response_time_threshold (FRT), next_response_time_threshold (NRT),
|
||||
// resolution_time_threshold (RT), only_during_business_hours
|
||||
type SlaPolicy struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
AccountID uint `gorm:"not null;index" json:"account_id"`
|
||||
Name string `gorm:"size:255;not null" json:"name"`
|
||||
Description string `gorm:"type:text" json:"description"`
|
||||
FirstResponseTimeThreshold int `gorm:"default:0" json:"first_response_time_threshold"` // FRT in minutes
|
||||
NextResponseTimeThreshold int `gorm:"default:0" json:"next_response_time_threshold"` // NRT in minutes
|
||||
ResolutionTimeThreshold int `gorm:"default:0" json:"resolution_time_threshold"` // RT in minutes
|
||||
OnlyDuringBusinessHours bool `gorm:"default:false" json:"only_during_business_hours"`
|
||||
PausedAt *time.Time `gorm:"index" json:"paused_at,omitempty"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
AccountID uint `gorm:"not null;index" json:"account_id"`
|
||||
Name string `gorm:"size:255;not null" json:"name"`
|
||||
Description string `gorm:"type:text" json:"description"`
|
||||
FirstResponseTimeThreshold int `gorm:"default:0" json:"first_response_time_threshold"` // FRT in seconds
|
||||
NextResponseTimeThreshold int `gorm:"default:0" json:"next_response_time_threshold"` // NRT in seconds
|
||||
ResolutionTimeThreshold int `gorm:"default:0" json:"resolution_time_threshold"` // RT in seconds
|
||||
OnlyDuringBusinessHours bool `gorm:"default:false" json:"only_during_business_hours"`
|
||||
PausedAt *time.Time `gorm:"index" json:"paused_at,omitempty"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
|
||||
|
||||
Account Account `gorm:"foreignKey:AccountID" json:"account,omitempty"`
|
||||
AppliedSLAs []AppliedSLA `gorm:"foreignKey:SlaPolicyID" json:"applied_slas,omitempty"`
|
||||
SlaPolicyInboxes []SlaPolicyInbox `gorm:"foreignKey:SlaPolicyID" json:"inboxes,omitempty"`
|
||||
Account Account `gorm:"foreignKey:AccountID" json:"account,omitempty"`
|
||||
AppliedSLAs []AppliedSLA `gorm:"foreignKey:SlaPolicyID" json:"applied_slas,omitempty"`
|
||||
SlaPolicyInboxes []SlaPolicyInbox `gorm:"foreignKey:SlaPolicyID" json:"inboxes,omitempty"`
|
||||
}
|
||||
|
||||
func (SlaPolicy) TableName() string { return "sla_policies" }
|
||||
@@ -54,26 +54,26 @@ func (SlaPolicyInbox) TableName() string { return "sla_policy_inboxes" }
|
||||
// Reference: Chatwoot app/models/applied_sla.rb
|
||||
// Tracks SLA status, target timestamps (FRT/NRT/RT), and actual completion times.
|
||||
type AppliedSLA struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
SlaPolicyID uint `gorm:"not null;index" json:"sla_policy_id"`
|
||||
ConversationID uint `gorm:"not null;index" json:"conversation_id"`
|
||||
AccountID uint `gorm:"not null;index" json:"account_id"`
|
||||
SLAStatus SLAStatus `gorm:"size:50;not null;default:active" json:"sla_status"`
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
SlaPolicyID uint `gorm:"not null;index" json:"sla_policy_id"`
|
||||
ConversationID uint `gorm:"not null;index" json:"conversation_id"`
|
||||
AccountID uint `gorm:"not null;index" json:"account_id"`
|
||||
SLAStatus SLAStatus `gorm:"size:50;not null;default:active" json:"sla_status"`
|
||||
// Target timestamps for FRT/NRT/RT (computed from policy + created_at)
|
||||
FRTTargetAt *time.Time `gorm:"index" json:"frt_target_at,omitempty"`
|
||||
NRTTargetAt *time.Time `gorm:"index" json:"nrt_target_at,omitempty"`
|
||||
RTTargetAt *time.Time `gorm:"index" json:"rt_target_at,omitempty"`
|
||||
FRTTargetAt *time.Time `gorm:"index" json:"frt_target_at,omitempty"`
|
||||
NRTTargetAt *time.Time `gorm:"index" json:"nrt_target_at,omitempty"`
|
||||
RTTargetAt *time.Time `gorm:"index" json:"rt_target_at,omitempty"`
|
||||
// Actual timestamps when targets were hit or violated
|
||||
FRTActualAt *time.Time `json:"frt_actual_at,omitempty"`
|
||||
NRTActualAt *time.Time `json:"nrt_actual_at,omitempty"`
|
||||
RTActualAt *time.Time `json:"rt_actual_at,omitempty"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
|
||||
FRTActualAt *time.Time `json:"frt_actual_at,omitempty"`
|
||||
NRTActualAt *time.Time `json:"nrt_actual_at,omitempty"`
|
||||
RTActualAt *time.Time `json:"rt_actual_at,omitempty"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
|
||||
|
||||
SlaPolicy SlaPolicy `gorm:"foreignKey:SlaPolicyID" json:"sla_policy,omitempty"`
|
||||
Conversation Conversation `gorm:"foreignKey:ConversationID" json:"conversation,omitempty"`
|
||||
SlaEvents []SlaEvent `gorm:"foreignKey:AppliedSlaID" json:"sla_events,omitempty"`
|
||||
SlaPolicy SlaPolicy `gorm:"foreignKey:SlaPolicyID" json:"sla_policy,omitempty"`
|
||||
Conversation Conversation `gorm:"foreignKey:ConversationID" json:"conversation,omitempty"`
|
||||
SlaEvents []SlaEvent `gorm:"foreignKey:AppliedSlaID" json:"sla_events,omitempty"`
|
||||
}
|
||||
|
||||
func (AppliedSLA) TableName() string { return "applied_slas" }
|
||||
@@ -82,20 +82,20 @@ func (AppliedSLA) TableName() string { return "applied_slas" }
|
||||
// Reference: Chatwoot app/models/sla_event.rb
|
||||
// Fields: event_type (frt/nrt/rt), meta (jsonb), account_id, conversation_id, inbox_id, sla_policy_id
|
||||
type SlaEvent struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
AppliedSlaID uint `gorm:"not null;index" json:"applied_sla_id"`
|
||||
AccountID uint `gorm:"not null;index" json:"account_id"`
|
||||
ConversationID uint `gorm:"not null;index" json:"conversation_id"`
|
||||
InboxID uint `gorm:"not null;index" json:"inbox_id"`
|
||||
SlaPolicyID uint `gorm:"not null;index" json:"sla_policy_id"`
|
||||
EventType SLAEventType `gorm:"size:50;not null" json:"event_type"`
|
||||
Meta datatypes.JSON `gorm:"type:jsonb" json:"meta,omitempty"` // e.g. {"message_id": 123} for NRT events
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
AppliedSlaID uint `gorm:"not null;index" json:"applied_sla_id"`
|
||||
AccountID uint `gorm:"not null;index" json:"account_id"`
|
||||
ConversationID uint `gorm:"not null;index" json:"conversation_id"`
|
||||
InboxID uint `gorm:"not null;index" json:"inbox_id"`
|
||||
SlaPolicyID uint `gorm:"not null;index" json:"sla_policy_id"`
|
||||
EventType SLAEventType `gorm:"size:50;not null" json:"event_type"`
|
||||
Meta datatypes.JSON `gorm:"type:jsonb" json:"meta,omitempty"` // e.g. {"message_id": 123} for NRT events
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
|
||||
|
||||
AppliedSLA AppliedSLA `gorm:"foreignKey:AppliedSlaID" json:"applied_sla,omitempty"`
|
||||
Conversation Conversation `gorm:"foreignKey:ConversationID" json:"conversation,omitempty"`
|
||||
AppliedSLA AppliedSLA `gorm:"foreignKey:AppliedSlaID" json:"applied_sla,omitempty"`
|
||||
Conversation Conversation `gorm:"foreignKey:ConversationID" json:"conversation,omitempty"`
|
||||
}
|
||||
|
||||
func (SlaEvent) TableName() string { return "sla_events" }
|
||||
|
||||
@@ -3,6 +3,7 @@ package repository
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
@@ -128,6 +129,20 @@ func (r *SlaEventRepo) FindByAppliedSla(ctx context.Context, appliedSlaID uint)
|
||||
return events, nil
|
||||
}
|
||||
|
||||
// ExistsByAppliedSlaEventAndMeta checks whether a missed SLA threshold was already recorded.
|
||||
func (r *SlaEventRepo) ExistsByAppliedSlaEventAndMeta(ctx context.Context, appliedSlaID uint, eventType model.SLAEventType, meta datatypes.JSON) (bool, error) {
|
||||
var count int64
|
||||
query := r.db.WithContext(ctx).Model(&model.SlaEvent{}).
|
||||
Where("applied_sla_id = ? AND event_type = ?", appliedSlaID, eventType)
|
||||
if len(meta) > 0 {
|
||||
query = query.Where("meta = ?", meta)
|
||||
}
|
||||
if err := query.Count(&count).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// Create creates a new SLA event.
|
||||
func (r *SlaEventRepo) Create(ctx context.Context, event *model.SlaEvent) error {
|
||||
return r.db.WithContext(ctx).Create(event).Error
|
||||
|
||||
@@ -2,12 +2,14 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/repository"
|
||||
applogger "github.com/gochat/gochat/pkg/logger"
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
// AppliedSlaService implements business logic for Applied SLA evaluation and lifecycle.
|
||||
@@ -91,8 +93,8 @@ func (s *AppliedSlaService) CreateFromConversation(ctx context.Context, accountI
|
||||
}
|
||||
}
|
||||
|
||||
// Compute target timestamps from policy thresholds + conversation created_at
|
||||
// Chatwoot: threshold is in minutes
|
||||
// Compute target timestamps from policy thresholds + conversation created_at.
|
||||
// Chatwoot stores SLA thresholds in seconds.
|
||||
applied := &model.AppliedSLA{
|
||||
SlaPolicyID: slaPolicyID,
|
||||
ConversationID: conversationID,
|
||||
@@ -100,9 +102,9 @@ func (s *AppliedSlaService) CreateFromConversation(ctx context.Context, accountI
|
||||
SLAStatus: model.SLAStatusActive,
|
||||
}
|
||||
|
||||
// FRT target: conversation.created_at + first_response_time_threshold (minutes)
|
||||
// FRT target: conversation.created_at + first_response_time_threshold seconds.
|
||||
if policy.FirstResponseTimeThreshold > 0 {
|
||||
frtTarget := conversation.CreatedAt.Add(time.Duration(policy.FirstResponseTimeThreshold) * time.Minute)
|
||||
frtTarget := conversation.CreatedAt.Add(time.Duration(policy.FirstResponseTimeThreshold) * time.Second)
|
||||
applied.FRTTargetAt = &frtTarget
|
||||
}
|
||||
|
||||
@@ -112,17 +114,17 @@ func (s *AppliedSlaService) CreateFromConversation(ctx context.Context, accountI
|
||||
if policy.NextResponseTimeThreshold > 0 {
|
||||
// Initial NRT target = FRT target (or created_at + NRT as fallback)
|
||||
if applied.FRTTargetAt != nil {
|
||||
nrtTarget := applied.FRTTargetAt.Add(time.Duration(policy.NextResponseTimeThreshold) * time.Minute)
|
||||
nrtTarget := applied.FRTTargetAt.Add(time.Duration(policy.NextResponseTimeThreshold) * time.Second)
|
||||
applied.NRTTargetAt = &nrtTarget
|
||||
} else {
|
||||
nrtTarget := conversation.CreatedAt.Add(time.Duration(policy.NextResponseTimeThreshold) * time.Minute)
|
||||
nrtTarget := conversation.CreatedAt.Add(time.Duration(policy.NextResponseTimeThreshold) * time.Second)
|
||||
applied.NRTTargetAt = &nrtTarget
|
||||
}
|
||||
}
|
||||
|
||||
// RT target: conversation.created_at + resolution_time_threshold (minutes)
|
||||
// RT target: conversation.created_at + resolution_time_threshold seconds.
|
||||
if policy.ResolutionTimeThreshold > 0 {
|
||||
rtTarget := conversation.CreatedAt.Add(time.Duration(policy.ResolutionTimeThreshold) * time.Minute)
|
||||
rtTarget := conversation.CreatedAt.Add(time.Duration(policy.ResolutionTimeThreshold) * time.Second)
|
||||
applied.RTTargetAt = &rtTarget
|
||||
}
|
||||
|
||||
@@ -157,6 +159,9 @@ func (s *AppliedSlaService) Evaluate(ctx context.Context, appliedSlaID uint) (*m
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("applied sla not found: %w", err)
|
||||
}
|
||||
if applied.SLAStatus == model.SLAStatusHit || applied.SLAStatus == model.SLAStatusMissed {
|
||||
return applied, nil
|
||||
}
|
||||
|
||||
policy, err := s.slaPolicyRepo.FindByID(ctx, applied.SlaPolicyID)
|
||||
if err != nil {
|
||||
@@ -173,8 +178,8 @@ func (s *AppliedSlaService) Evaluate(ctx context.Context, appliedSlaID uint) (*m
|
||||
// threshold = conversation.created_at + policy.frt
|
||||
// Missed if: first_reply_created_at is nil AND now > threshold
|
||||
// Or: first_reply_created_at > threshold
|
||||
now := time.Now()
|
||||
if policy.FirstResponseTimeThreshold > 0 && applied.FRTTargetAt != nil {
|
||||
now := time.Now()
|
||||
if s.checkFRTMissed(conversation, applied, now) {
|
||||
s.handleMissedSla(ctx, applied, model.SLAEventFRT, conversation)
|
||||
} else if conversation.FirstReplyCreatedAt != nil {
|
||||
@@ -192,7 +197,6 @@ func (s *AppliedSlaService) Evaluate(ctx context.Context, appliedSlaID uint) (*m
|
||||
// Skip if: waiting_since is nil (customer not waiting)
|
||||
// threshold = waiting_since + policy.nrt
|
||||
if policy.NextResponseTimeThreshold > 0 && applied.NRTTargetAt != nil {
|
||||
now := time.Now()
|
||||
if s.checkNRTMissed(conversation, applied, now) {
|
||||
s.handleMissedSla(ctx, applied, model.SLAEventNRT, conversation)
|
||||
}
|
||||
@@ -203,7 +207,6 @@ func (s *AppliedSlaService) Evaluate(ctx context.Context, appliedSlaID uint) (*m
|
||||
// threshold = conversation.created_at + policy.rt
|
||||
// Missed if: now > threshold AND conversation not yet resolved
|
||||
if policy.ResolutionTimeThreshold > 0 && applied.RTTargetAt != nil {
|
||||
now := time.Now()
|
||||
if s.checkRTMissed(conversation, applied, now) {
|
||||
s.handleMissedSla(ctx, applied, model.SLAEventRT, conversation)
|
||||
}
|
||||
@@ -232,7 +235,7 @@ func (s *AppliedSlaService) checkFRTMissed(conversation *model.Conversation, app
|
||||
|
||||
// No first reply yet — check if we're past the deadline
|
||||
if conversation.FirstReplyCreatedAt == nil {
|
||||
return now.After(*target)
|
||||
return !now.Before(*target)
|
||||
}
|
||||
|
||||
// First reply happened — was it within the threshold?
|
||||
@@ -264,12 +267,12 @@ func (s *AppliedSlaService) checkNRTMissed(conversation *model.Conversation, app
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
nrtTarget := waitingSince.Add(time.Duration(policy.NextResponseTimeThreshold) * time.Minute)
|
||||
nrtTarget := waitingSince.Add(time.Duration(policy.NextResponseTimeThreshold) * time.Second)
|
||||
|
||||
// Update the NRT target timestamp
|
||||
applied.NRTTargetAt = &nrtTarget
|
||||
|
||||
return now.After(nrtTarget)
|
||||
return !now.Before(nrtTarget)
|
||||
}
|
||||
|
||||
// checkRTMissed checks if the resolution time threshold was missed.
|
||||
@@ -279,12 +282,25 @@ func (s *AppliedSlaService) checkRTMissed(conversation *model.Conversation, appl
|
||||
if conversation.Status == string(model.ConversationStatusResolved) {
|
||||
return false // Already resolved — will be handled by handleHitSla
|
||||
}
|
||||
return now.After(*applied.RTTargetAt)
|
||||
return !now.Before(*applied.RTTargetAt)
|
||||
}
|
||||
|
||||
// handleMissedSla creates a SlaEvent for a missed threshold and updates status.
|
||||
// Chatwoot: handle_missed_sla → creates SlaEvent with event_type, updates sla_status
|
||||
func (s *AppliedSlaService) handleMissedSla(ctx context.Context, applied *model.AppliedSLA, eventType model.SLAEventType, conversation *model.Conversation) {
|
||||
meta := s.slaEventMeta(ctx, eventType, conversation)
|
||||
alreadyMissed, err := s.slaEventRepo.ExistsByAppliedSlaEventAndMeta(ctx, applied.ID, eventType, meta)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Failed to check existing SLA event for applied_sla %d: %v", applied.ID, err)
|
||||
return
|
||||
}
|
||||
if alreadyMissed {
|
||||
if applied.SLAStatus == model.SLAStatusActive {
|
||||
applied.SLAStatus = model.SLAStatusActiveWithMisses
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Create SlaEvent record
|
||||
event := &model.SlaEvent{
|
||||
AppliedSlaID: applied.ID,
|
||||
@@ -293,6 +309,7 @@ func (s *AppliedSlaService) handleMissedSla(ctx context.Context, applied *model.
|
||||
InboxID: conversation.InboxID,
|
||||
SlaPolicyID: applied.SlaPolicyID,
|
||||
EventType: eventType,
|
||||
Meta: meta,
|
||||
}
|
||||
|
||||
if err := s.slaEventRepo.Create(ctx, event); err != nil {
|
||||
@@ -309,6 +326,37 @@ func (s *AppliedSlaService) handleMissedSla(ctx context.Context, applied *model.
|
||||
applogger.L().Infof("SLA %s missed for applied_sla %d on conversation %d", eventType, applied.ID, applied.ConversationID)
|
||||
}
|
||||
|
||||
func (s *AppliedSlaService) slaEventMeta(ctx context.Context, eventType model.SLAEventType, conversation *model.Conversation) datatypes.JSON {
|
||||
if eventType != model.SLAEventNRT {
|
||||
return datatypes.JSON([]byte("{}"))
|
||||
}
|
||||
|
||||
meta := map[string]any{"message_id": nil}
|
||||
if messageID := s.lastIncomingMessageID(ctx, conversation); messageID != nil {
|
||||
meta["message_id"] = *messageID
|
||||
}
|
||||
encoded, err := json.Marshal(meta)
|
||||
if err != nil {
|
||||
return datatypes.JSON([]byte(`{"message_id":null}`))
|
||||
}
|
||||
return datatypes.JSON(encoded)
|
||||
}
|
||||
|
||||
func (s *AppliedSlaService) lastIncomingMessageID(ctx context.Context, conversation *model.Conversation) *uint {
|
||||
if s.conversationRepo == nil || conversation == nil {
|
||||
return nil
|
||||
}
|
||||
var message model.Message
|
||||
err := s.conversationRepo.DB().WithContext(ctx).
|
||||
Where("account_id = ? AND conversation_id = ? AND message_type = ?", conversation.AccountID, conversation.ID, string(model.MessageTypeIncoming)).
|
||||
Order("id DESC").
|
||||
First(&message).Error
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &message.ID
|
||||
}
|
||||
|
||||
// handleHitSla marks the SLA as hit when the conversation is resolved.
|
||||
// Chatwoot: handle_hit_sla → if resolved, check if any thresholds were missed
|
||||
// - If active_with_misses → missed (not hit)
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/repository"
|
||||
)
|
||||
|
||||
func setupAppliedSlaLifecycleTest(t *testing.T) (*AppliedSlaService, *gorm.DB) {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(
|
||||
&model.Account{},
|
||||
&model.Inbox{},
|
||||
&model.Contact{},
|
||||
&model.Conversation{},
|
||||
&model.Message{},
|
||||
&model.SlaPolicy{},
|
||||
&model.AppliedSLA{},
|
||||
&model.SlaEvent{},
|
||||
))
|
||||
t.Cleanup(func() {
|
||||
sqlDB, _ := db.DB()
|
||||
sqlDB.Close()
|
||||
})
|
||||
|
||||
convRepo := repository.NewConversationRepo(db)
|
||||
svc := NewAppliedSlaService(
|
||||
repository.NewAppliedSlaRepo(db),
|
||||
repository.NewSlaEventRepo(db),
|
||||
repository.NewSlaPolicyRepo(db),
|
||||
convRepo,
|
||||
)
|
||||
return svc, db
|
||||
}
|
||||
|
||||
func seedAppliedSlaConversation(t *testing.T, db *gorm.DB, createdAt time.Time, policy model.SlaPolicy) (*model.Account, *model.Inbox, *model.Contact, *model.SlaPolicy, *model.Conversation) {
|
||||
t.Helper()
|
||||
account := &model.Account{Name: "SLA Lifecycle Org", Locale: "en", Active: true}
|
||||
require.NoError(t, db.Create(account).Error)
|
||||
inbox := &model.Inbox{AccountID: account.ID, Name: "SLA Inbox", ChannelType: "web_widget", ChannelID: 1, Enabled: true}
|
||||
require.NoError(t, db.Create(inbox).Error)
|
||||
contact := &model.Contact{AccountID: account.ID, Name: "SLA Contact"}
|
||||
require.NoError(t, db.Create(contact).Error)
|
||||
policy.AccountID = account.ID
|
||||
if policy.Name == "" {
|
||||
policy.Name = "SLA Policy"
|
||||
}
|
||||
require.NoError(t, db.Create(&policy).Error)
|
||||
conversation := &model.Conversation{
|
||||
Base: model.Base{
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt,
|
||||
},
|
||||
AccountID: account.ID,
|
||||
InboxID: inbox.ID,
|
||||
ContactID: contact.ID,
|
||||
Status: string(model.ConversationStatusOpen),
|
||||
ChannelType: "web_widget",
|
||||
Channel: "web_widget",
|
||||
}
|
||||
require.NoError(t, db.Create(conversation).Error)
|
||||
return account, inbox, contact, &policy, conversation
|
||||
}
|
||||
|
||||
func TestAppliedSlaService_Evaluate_FRTMissIsIdempotent(t *testing.T) {
|
||||
svc, db := setupAppliedSlaLifecycleTest(t)
|
||||
createdAt := time.Now().Add(-2 * time.Hour)
|
||||
account, _, _, policy, conversation := seedAppliedSlaConversation(t, db, createdAt, model.SlaPolicy{
|
||||
FirstResponseTimeThreshold: 60,
|
||||
})
|
||||
|
||||
applied, err := svc.CreateFromConversation(context.Background(), account.ID, conversation.ID, policy.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
applied, err = svc.Evaluate(context.Background(), applied.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, model.SLAStatusActiveWithMisses, applied.SLAStatus)
|
||||
|
||||
_, err = svc.Evaluate(context.Background(), applied.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
var events []model.SlaEvent
|
||||
require.NoError(t, db.Where("applied_sla_id = ?", applied.ID).Find(&events).Error)
|
||||
require.Len(t, events, 1)
|
||||
assert.Equal(t, model.SLAEventFRT, events[0].EventType)
|
||||
assert.JSONEq(t, `{}`, string(events[0].Meta))
|
||||
}
|
||||
|
||||
func TestAppliedSlaService_Evaluate_NRTMissStoresMessageMetaOnce(t *testing.T) {
|
||||
svc, db := setupAppliedSlaLifecycleTest(t)
|
||||
createdAt := time.Now().Add(-2 * time.Hour)
|
||||
account, inbox, _, policy, conversation := seedAppliedSlaConversation(t, db, createdAt, model.SlaPolicy{
|
||||
NextResponseTimeThreshold: 60,
|
||||
})
|
||||
firstReply := time.Now().Add(-90 * time.Minute).Unix()
|
||||
waitingSince := time.Now().Add(-10 * time.Minute).Unix()
|
||||
require.NoError(t, db.Model(conversation).Updates(map[string]any{
|
||||
"first_reply_created_at": firstReply,
|
||||
"waiting_since": waitingSince,
|
||||
}).Error)
|
||||
conversation.FirstReplyCreatedAt = &firstReply
|
||||
conversation.WaitingSince = &waitingSince
|
||||
|
||||
message := &model.Message{
|
||||
AccountID: account.ID,
|
||||
InboxID: inbox.ID,
|
||||
ConversationID: conversation.ID,
|
||||
MessageType: string(model.MessageTypeIncoming),
|
||||
Content: "customer waiting",
|
||||
ContentType: "text",
|
||||
}
|
||||
require.NoError(t, db.Create(message).Error)
|
||||
|
||||
applied, err := svc.CreateFromConversation(context.Background(), account.ID, conversation.ID, policy.ID)
|
||||
require.NoError(t, err)
|
||||
_, err = svc.Evaluate(context.Background(), applied.ID)
|
||||
require.NoError(t, err)
|
||||
_, err = svc.Evaluate(context.Background(), applied.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
var events []model.SlaEvent
|
||||
require.NoError(t, db.Where("applied_sla_id = ?", applied.ID).Find(&events).Error)
|
||||
require.Len(t, events, 1)
|
||||
assert.Equal(t, model.SLAEventNRT, events[0].EventType)
|
||||
|
||||
var meta map[string]uint
|
||||
require.NoError(t, json.Unmarshal(events[0].Meta, &meta))
|
||||
assert.Equal(t, message.ID, meta["message_id"])
|
||||
}
|
||||
|
||||
func TestAppliedSlaService_Evaluate_ResolvedConversationHitOrMiss(t *testing.T) {
|
||||
svc, db := setupAppliedSlaLifecycleTest(t)
|
||||
now := time.Now()
|
||||
account, _, _, policy, conversation := seedAppliedSlaConversation(t, db, now.Add(-10*time.Minute), model.SlaPolicy{
|
||||
FirstResponseTimeThreshold: 3600,
|
||||
ResolutionTimeThreshold: 7200,
|
||||
})
|
||||
firstReply := now.Add(-9 * time.Minute).Unix()
|
||||
resolvedAt := now
|
||||
require.NoError(t, db.Model(conversation).Updates(map[string]any{
|
||||
"first_reply_created_at": firstReply,
|
||||
"status": string(model.ConversationStatusResolved),
|
||||
"resolved_at": resolvedAt,
|
||||
}).Error)
|
||||
|
||||
applied, err := svc.CreateFromConversation(context.Background(), account.ID, conversation.ID, policy.ID)
|
||||
require.NoError(t, err)
|
||||
applied, err = svc.Evaluate(context.Background(), applied.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, model.SLAStatusHit, applied.SLAStatus)
|
||||
|
||||
account2, _, _, policy2, conversation2 := seedAppliedSlaConversation(t, db, now.Add(-2*time.Hour), model.SlaPolicy{
|
||||
FirstResponseTimeThreshold: 60,
|
||||
})
|
||||
applied2, err := svc.CreateFromConversation(context.Background(), account2.ID, conversation2.ID, policy2.ID)
|
||||
require.NoError(t, err)
|
||||
applied2, err = svc.Evaluate(context.Background(), applied2.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, model.SLAStatusActiveWithMisses, applied2.SLAStatus)
|
||||
|
||||
require.NoError(t, db.Model(conversation2).Updates(map[string]any{
|
||||
"status": string(model.ConversationStatusResolved),
|
||||
"resolved_at": now,
|
||||
}).Error)
|
||||
applied2, err = svc.Evaluate(context.Background(), applied2.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, model.SLAStatusMissed, applied2.SLAStatus)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -142,7 +143,7 @@ func TestConversationService_Create_AppliesSlaPolicy(t *testing.T) {
|
||||
require.NotNil(t, applied.FRTTargetAt)
|
||||
require.NotNil(t, applied.NRTTargetAt)
|
||||
require.NotNil(t, applied.RTTargetAt)
|
||||
assert.Equal(t, conversation.CreatedAt.Add(10*60*1e9).Unix(), applied.FRTTargetAt.Unix())
|
||||
assert.Equal(t, conversation.CreatedAt.Add(10*time.Second).Unix(), applied.FRTTargetAt.Unix())
|
||||
}
|
||||
|
||||
func TestConversationService_Update_AppliesSlaPolicyOnce(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user