feat(sla): notify users on sla misses

This commit is contained in:
2026-06-05 07:59:51 +08:00
parent 64926db464
commit d23f3f09f1
2 changed files with 216 additions and 0 deletions
+101
View File
@@ -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(&notifications).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(&notification).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)