diff --git a/internal/model/sla_policy.go b/internal/model/sla_policy.go index 6401f549..4c7f2e0f 100644 --- a/internal/model/sla_policy.go +++ b/internal/model/sla_policy.go @@ -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" } diff --git a/internal/repository/sla_policy_repo.go b/internal/repository/sla_policy_repo.go index ff5333c6..4418900b 100644 --- a/internal/repository/sla_policy_repo.go +++ b/internal/repository/sla_policy_repo.go @@ -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 diff --git a/internal/service/applied_sla_service.go b/internal/service/applied_sla_service.go index 60f925c9..e25f6c5e 100644 --- a/internal/service/applied_sla_service.go +++ b/internal/service/applied_sla_service.go @@ -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) diff --git a/internal/service/applied_sla_service_test.go b/internal/service/applied_sla_service_test.go new file mode 100644 index 00000000..fde9e3eb --- /dev/null +++ b/internal/service/applied_sla_service_test.go @@ -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) +} diff --git a/internal/service/conversation_service_test.go b/internal/service/conversation_service_test.go index 49cde18e..273d7759 100644 --- a/internal/service/conversation_service_test.go +++ b/internal/service/conversation_service_test.go @@ -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) {