feat(sla): notify users on sla misses
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
@@ -316,6 +317,7 @@ func (s *AppliedSlaService) handleMissedSla(ctx context.Context, applied *model.
|
||||
applogger.L().Errorf("Failed to create SLA event for applied_sla %d: %v", applied.ID, err)
|
||||
return
|
||||
}
|
||||
s.createSlaMissNotifications(ctx, event, eventType, conversation)
|
||||
|
||||
// Update AppliedSLA status: active → active_with_misses (if not already)
|
||||
// Chatwoot: sla_status transitions: active → active_with_misses → missed/hit
|
||||
@@ -326,6 +328,105 @@ 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) createSlaMissNotifications(ctx context.Context, event *model.SlaEvent, eventType model.SLAEventType, conversation *model.Conversation) {
|
||||
if s.conversationRepo == nil || event == nil || conversation == nil {
|
||||
return
|
||||
}
|
||||
notificationType := slaMissNotificationType(eventType)
|
||||
if notificationType == "" {
|
||||
return
|
||||
}
|
||||
|
||||
userIDs, err := s.slaNotificationUserIDs(ctx, conversation)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Failed to resolve SLA notification users for event %d: %v", event.ID, err)
|
||||
return
|
||||
}
|
||||
accountID := event.AccountID
|
||||
for _, userID := range userIDs {
|
||||
notification := &model.Notification{
|
||||
AccountID: &accountID,
|
||||
UserID: userID,
|
||||
NotificationType: notificationType,
|
||||
PrimaryActorType: "Conversation",
|
||||
PrimaryActorID: conversation.ID,
|
||||
SecondaryActorType: "SlaPolicy",
|
||||
SecondaryActorID: event.SlaPolicyID,
|
||||
}
|
||||
if err := s.conversationRepo.DB().WithContext(ctx).Create(notification).Error; err != nil {
|
||||
applogger.L().Errorf("Failed to create SLA notification for event %d user %d: %v", event.ID, userID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func slaMissNotificationType(eventType model.SLAEventType) string {
|
||||
switch eventType {
|
||||
case model.SLAEventFRT:
|
||||
return "sla_missed_first_response"
|
||||
case model.SLAEventNRT:
|
||||
return "sla_missed_next_response"
|
||||
case model.SLAEventRT:
|
||||
return "sla_missed_resolution"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AppliedSlaService) slaNotificationUserIDs(ctx context.Context, conversation *model.Conversation) ([]uint, error) {
|
||||
seen := map[uint]struct{}{}
|
||||
add := func(userID uint) {
|
||||
if userID == 0 {
|
||||
return
|
||||
}
|
||||
seen[userID] = struct{}{}
|
||||
}
|
||||
|
||||
var participants []model.ConversationParticipant
|
||||
if err := s.conversationRepo.DB().WithContext(ctx).
|
||||
Where("account_id = ? AND conversation_id = ?", conversation.AccountID, conversation.ID).
|
||||
Find(&participants).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, participant := range participants {
|
||||
add(participant.UserID)
|
||||
}
|
||||
|
||||
var admins []model.AccountUser
|
||||
if err := s.conversationRepo.DB().WithContext(ctx).
|
||||
Where("account_id = ? AND role = ?", conversation.AccountID, "administrator").
|
||||
Find(&admins).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, admin := range admins {
|
||||
add(admin.UserID)
|
||||
}
|
||||
|
||||
if conversation.AssigneeID != nil {
|
||||
add(*conversation.AssigneeID)
|
||||
}
|
||||
|
||||
ids := make([]uint, 0, len(seen))
|
||||
for userID := range seen {
|
||||
ids = append(ids, userID)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
var accessible []model.AccountUser
|
||||
if err := s.conversationRepo.DB().WithContext(ctx).
|
||||
Where("account_id = ? AND user_id IN ?", conversation.AccountID, ids).
|
||||
Find(&accessible).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids = ids[:0]
|
||||
for _, accountUser := range accessible {
|
||||
ids = append(ids, accountUser.UserID)
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (s *AppliedSlaService) slaEventMeta(ctx context.Context, eventType model.SLAEventType, conversation *model.Conversation) datatypes.JSON {
|
||||
if eventType != model.SLAEventNRT {
|
||||
return datatypes.JSON([]byte("{}"))
|
||||
|
||||
@@ -22,13 +22,17 @@ func setupAppliedSlaLifecycleTest(t *testing.T) (*AppliedSlaService, *gorm.DB) {
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(
|
||||
&model.Account{},
|
||||
&model.User{},
|
||||
&model.AccountUser{},
|
||||
&model.Inbox{},
|
||||
&model.Contact{},
|
||||
&model.Conversation{},
|
||||
&model.ConversationParticipant{},
|
||||
&model.Message{},
|
||||
&model.SlaPolicy{},
|
||||
&model.AppliedSLA{},
|
||||
&model.SlaEvent{},
|
||||
&model.Notification{},
|
||||
))
|
||||
t.Cleanup(func() {
|
||||
sqlDB, _ := db.DB()
|
||||
@@ -98,6 +102,117 @@ func TestAppliedSlaService_Evaluate_FRTMissIsIdempotent(t *testing.T) {
|
||||
assert.JSONEq(t, `{}`, string(events[0].Meta))
|
||||
}
|
||||
|
||||
func TestAppliedSlaService_Evaluate_CreatesSlaMissNotifications(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,
|
||||
})
|
||||
|
||||
participant := &model.User{AccountID: account.ID, Name: "Participant", Email: "participant@example.com", Role: "agent", Active: true}
|
||||
admin := &model.User{AccountID: account.ID, Name: "Admin", Email: "admin@example.com", Role: "administrator", Active: true}
|
||||
assignee := &model.User{AccountID: account.ID, Name: "Assignee", Email: "assignee@example.com", Role: "agent", Active: true}
|
||||
removedParticipant := &model.User{AccountID: account.ID, Name: "Removed", Email: "removed@example.com", Role: "agent", Active: true}
|
||||
require.NoError(t, db.Create(participant).Error)
|
||||
require.NoError(t, db.Create(admin).Error)
|
||||
require.NoError(t, db.Create(assignee).Error)
|
||||
require.NoError(t, db.Create(removedParticipant).Error)
|
||||
require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: participant.ID, Role: "agent"}).Error)
|
||||
require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: admin.ID, Role: "administrator"}).Error)
|
||||
require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: assignee.ID, Role: "agent"}).Error)
|
||||
require.NoError(t, db.Create(&model.ConversationParticipant{AccountID: account.ID, ConversationID: conversation.ID, UserID: participant.ID}).Error)
|
||||
require.NoError(t, db.Create(&model.ConversationParticipant{AccountID: account.ID, ConversationID: conversation.ID, UserID: admin.ID}).Error)
|
||||
require.NoError(t, db.Create(&model.ConversationParticipant{AccountID: account.ID, ConversationID: conversation.ID, UserID: removedParticipant.ID}).Error)
|
||||
require.NoError(t, db.Model(conversation).Update("assignee_id", assignee.ID).Error)
|
||||
conversation.AssigneeID = &assignee.ID
|
||||
|
||||
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 notifications []model.Notification
|
||||
require.NoError(t, db.Order("user_id ASC").Find(¬ifications).Error)
|
||||
require.Len(t, notifications, 3)
|
||||
assert.Equal(t, []uint{participant.ID, admin.ID, assignee.ID}, []uint{notifications[0].UserID, notifications[1].UserID, notifications[2].UserID})
|
||||
for _, notification := range notifications {
|
||||
assert.Equal(t, "sla_missed_first_response", notification.NotificationType)
|
||||
assert.Equal(t, "Conversation", notification.PrimaryActorType)
|
||||
assert.Equal(t, conversation.ID, notification.PrimaryActorID)
|
||||
assert.Equal(t, "SlaPolicy", notification.SecondaryActorType)
|
||||
assert.Equal(t, policy.ID, notification.SecondaryActorID)
|
||||
require.NotNil(t, notification.AccountID)
|
||||
assert.Equal(t, account.ID, *notification.AccountID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppliedSlaService_Evaluate_SlaNotificationTypes(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
policy model.SlaPolicy
|
||||
conversationPatch func(*testing.T, *gorm.DB, *model.Conversation)
|
||||
expectedType string
|
||||
}{
|
||||
{
|
||||
name: "next response",
|
||||
policy: model.SlaPolicy{NextResponseTimeThreshold: 60},
|
||||
conversationPatch: func(t *testing.T, db *gorm.DB, conversation *model.Conversation) {
|
||||
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)
|
||||
},
|
||||
expectedType: "sla_missed_next_response",
|
||||
},
|
||||
{
|
||||
name: "resolution",
|
||||
policy: model.SlaPolicy{ResolutionTimeThreshold: 60},
|
||||
conversationPatch: func(t *testing.T, db *gorm.DB, conversation *model.Conversation) {
|
||||
},
|
||||
expectedType: "sla_missed_resolution",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
svc, db := setupAppliedSlaLifecycleTest(t)
|
||||
createdAt := time.Now().Add(-2 * time.Hour)
|
||||
account, _, _, policy, conversation := seedAppliedSlaConversation(t, db, createdAt, tc.policy)
|
||||
user := &model.User{AccountID: account.ID, Name: "Watcher", Email: tc.name + "@example.com", Role: "agent", Active: true}
|
||||
require.NoError(t, db.Create(user).Error)
|
||||
require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: user.ID, Role: "agent"}).Error)
|
||||
require.NoError(t, db.Create(&model.ConversationParticipant{AccountID: account.ID, ConversationID: conversation.ID, UserID: user.ID}).Error)
|
||||
tc.conversationPatch(t, db, conversation)
|
||||
|
||||
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)
|
||||
|
||||
var notification model.Notification
|
||||
require.NoError(t, db.First(¬ification).Error)
|
||||
assert.Equal(t, tc.expectedType, notification.NotificationType)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppliedSlaService_CreateFromConversation_OnlyDuringBusinessHoursMatchesReferenceTiming(t *testing.T) {
|
||||
svc, db := setupAppliedSlaLifecycleTest(t)
|
||||
createdAt := time.Date(2026, 6, 5, 20, 0, 0, 0, time.UTC)
|
||||
account, _, _, policy, conversation := seedAppliedSlaConversation(t, db, createdAt, model.SlaPolicy{
|
||||
FirstResponseTimeThreshold: 3600,
|
||||
OnlyDuringBusinessHours: true,
|
||||
})
|
||||
|
||||
applied, err := svc.CreateFromConversation(context.Background(), account.ID, conversation.ID, policy.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, applied.FRTTargetAt)
|
||||
|
||||
// The current reference/chatwoot EvaluateAppliedSlaService stores the flag but
|
||||
// calculates thresholds as created_at + threshold seconds.
|
||||
assert.Equal(t, createdAt.Add(time.Hour), *applied.FRTTargetAt)
|
||||
}
|
||||
|
||||
func TestAppliedSlaService_Evaluate_NRTMissStoresMessageMetaOnce(t *testing.T) {
|
||||
svc, db := setupAppliedSlaLifecycleTest(t)
|
||||
createdAt := time.Now().Add(-2 * time.Hour)
|
||||
|
||||
Reference in New Issue
Block a user