diff --git a/internal/service/applied_sla_service.go b/internal/service/applied_sla_service.go index e25f6c5e..3ba39e18 100644 --- a/internal/service/applied_sla_service.go +++ b/internal/service/applied_sla_service.go @@ -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("{}")) diff --git a/internal/service/applied_sla_service_test.go b/internal/service/applied_sla_service_test.go index fde9e3eb..ad00c1b7 100644 --- a/internal/service/applied_sla_service_test.go +++ b/internal/service/applied_sla_service_test.go @@ -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)