package service import ( "context" "encoding/json" "fmt" "sort" "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. // Reference: Chatwoot enterprise/app/services/sla/evaluate_applied_sla_service.rb type AppliedSlaService struct { appliedSlaRepo *repository.AppliedSlaRepo slaEventRepo *repository.SlaEventRepo slaPolicyRepo *repository.SlaPolicyRepo conversationRepo *repository.ConversationRepo } // NewAppliedSlaService creates a new Applied SLA service. func NewAppliedSlaService( appliedSlaRepo *repository.AppliedSlaRepo, slaEventRepo *repository.SlaEventRepo, slaPolicyRepo *repository.SlaPolicyRepo, conversationRepo *repository.ConversationRepo, ) *AppliedSlaService { return &AppliedSlaService{ appliedSlaRepo: appliedSlaRepo, slaEventRepo: slaEventRepo, slaPolicyRepo: slaPolicyRepo, conversationRepo: conversationRepo, } } func (s *AppliedSlaService) ValidateSlaPolicy(ctx context.Context, accountID, slaPolicyID uint) error { policy, err := s.slaPolicyRepo.FindByID(ctx, slaPolicyID) if err != nil { return fmt.Errorf("sla policy not found: %w", err) } if policy.AccountID != accountID { return fmt.Errorf("sla policy %d does not belong to account %d", slaPolicyID, accountID) } return nil } // CreateFromConversation creates an AppliedSLA record when a conversation's // sla_policy_id is set or changed. // Reference: Chatwoot enterprise/app/models/enterprise/concerns/conversation.rb // - ensure_applied_sla_is_created: triggered around_save when sla_policy_id changes // - Validates: SLA policy belongs to the same account // - Creates AppliedSLA with target timestamps computed from policy thresholds func (s *AppliedSlaService) CreateFromConversation(ctx context.Context, accountID, conversationID, slaPolicyID uint) (*model.AppliedSLA, error) { // Chatwoot: validate_sla_policy — SLA policy must belong to the same account policy, err := s.slaPolicyRepo.FindByID(ctx, slaPolicyID) if err != nil { return nil, fmt.Errorf("sla policy not found: %w", err) } if policy.AccountID != accountID { return nil, fmt.Errorf("sla policy %d does not belong to account %d", slaPolicyID, accountID) } // Chatwoot: uniqueness scope [account_id, sla_policy_id, conversation_id] existing, err := s.appliedSlaRepo.FindByConversation(ctx, conversationID) if err == nil && existing != nil { // Chatwoot: "conversation already has a different sla" — cannot replace if existing.SlaPolicyID != slaPolicyID { return nil, fmt.Errorf("conversation %d already has SLA policy %d, cannot change to %d", conversationID, existing.SlaPolicyID, slaPolicyID) } // Same policy already applied — return existing return existing, nil } conversation, err := s.conversationRepo.FindByID(ctx, conversationID) if err != nil { return nil, fmt.Errorf("conversation not found: %w", err) } if conversation.AccountID != accountID { return nil, fmt.Errorf("conversation %d does not belong to account %d", conversationID, accountID) } if conversation.SlaPolicyID != nil && *conversation.SlaPolicyID != slaPolicyID { return nil, fmt.Errorf("conversation %d already has SLA policy %d, cannot change to %d", conversationID, *conversation.SlaPolicyID, slaPolicyID) } if conversation.SlaPolicyID == nil { conversation.SlaPolicyID = &slaPolicyID if err := s.conversationRepo.Update(ctx, conversation); err != nil { return nil, fmt.Errorf("set conversation sla policy: %w", err) } } // Compute target timestamps from policy thresholds + conversation created_at. // Chatwoot stores SLA thresholds in seconds. applied := &model.AppliedSLA{ SlaPolicyID: slaPolicyID, ConversationID: conversationID, AccountID: accountID, SLAStatus: model.SLAStatusActive, } // FRT target: conversation.created_at + first_response_time_threshold seconds. if policy.FirstResponseTimeThreshold > 0 { frtTarget := conversation.CreatedAt.Add(time.Duration(policy.FirstResponseTimeThreshold) * time.Second) applied.FRTTargetAt = &frtTarget } // NRT target: we set initial target, but it will be recalculated when // the agent sends a reply and the conversation goes back to "waiting" // Chatwoot: NRT is relative to waiting_since, which changes over time 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.Second) applied.NRTTargetAt = &nrtTarget } else { nrtTarget := conversation.CreatedAt.Add(time.Duration(policy.NextResponseTimeThreshold) * time.Second) applied.NRTTargetAt = &nrtTarget } } // RT target: conversation.created_at + resolution_time_threshold seconds. if policy.ResolutionTimeThreshold > 0 { rtTarget := conversation.CreatedAt.Add(time.Duration(policy.ResolutionTimeThreshold) * time.Second) applied.RTTargetAt = &rtTarget } // Set FRT actual if first reply already happened if conversation.FirstReplyCreatedAt != nil { frtActual := time.Unix(*conversation.FirstReplyCreatedAt, 0) applied.FRTActualAt = &frtActual } // Set RT actual if already resolved if conversation.ResolvedAt != nil { applied.RTActualAt = conversation.ResolvedAt } // Create the AppliedSLA record // Chatwoot uses create_applied_sla inside a transaction around_save if err := s.appliedSlaRepo.Create(ctx, applied); err != nil { return nil, fmt.Errorf("create applied sla: %w", err) } applogger.L().Infof("AppliedSLA %d created for conversation %d with policy %d", applied.ID, conversationID, slaPolicyID) return applied, nil } // Evaluate checks SLA thresholds for an existing AppliedSLA and updates its status. // Reference: Chatwoot enterprise/app/services/sla/evaluate_applied_sla_service.rb // - Checks each threshold (FRT, NRT, RT) independently // - If a threshold is missed: creates SlaEvent and marks active_with_misses // - If conversation is resolved: marks SLA as hit (all thresholds met) or missed func (s *AppliedSlaService) Evaluate(ctx context.Context, appliedSlaID uint) (*model.AppliedSLA, error) { applied, err := s.appliedSlaRepo.FindByID(ctx, appliedSlaID) 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 { return nil, fmt.Errorf("sla policy not found: %w", err) } conversation, err := s.conversationRepo.FindByID(ctx, applied.ConversationID) if err != nil { return nil, fmt.Errorf("conversation not found: %w", err) } // --- Check FRT threshold --- // Chatwoot: check_first_response_time_threshold // 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 { if s.checkFRTMissed(conversation, applied, now) { s.handleMissedSla(ctx, applied, model.SLAEventFRT, conversation) } else if conversation.FirstReplyCreatedAt != nil { // First reply was within threshold — record actual time frtActual := time.Unix(*conversation.FirstReplyCreatedAt, 0) if applied.FRTActualAt == nil { applied.FRTActualAt = &frtActual } } } // --- Check NRT threshold --- // Chatwoot: check_next_response_time_threshold // Skip if: first_reply_created_at is nil (covered by FRT) // Skip if: waiting_since is nil (customer not waiting) // threshold = waiting_since + policy.nrt if policy.NextResponseTimeThreshold > 0 && applied.NRTTargetAt != nil { if s.checkNRTMissed(conversation, applied, now) { s.handleMissedSla(ctx, applied, model.SLAEventNRT, conversation) } } // --- Check RT threshold --- // Chatwoot: check_resolution_time_threshold // threshold = conversation.created_at + policy.rt // Missed if: now > threshold AND conversation not yet resolved if policy.ResolutionTimeThreshold > 0 && applied.RTTargetAt != nil { if s.checkRTMissed(conversation, applied, now) { s.handleMissedSla(ctx, applied, model.SLAEventRT, conversation) } } // --- Handle resolved conversation --- // Chatwoot: "after conversation is resolved, we will check if the SLA was hit or missed" if conversation.Status == string(model.ConversationStatusResolved) { s.handleHitSla(ctx, applied, conversation) } // Persist the updated AppliedSLA if err := s.appliedSlaRepo.Update(ctx, applied); err != nil { return nil, fmt.Errorf("update applied sla: %w", err) } return applied, nil } // checkFRTMissed checks if the first response time threshold was missed. // Chatwoot: check_first_response_time_threshold // - Missed if no first reply yet AND now > frt_target // - Missed if first reply happened AFTER the target func (s *AppliedSlaService) checkFRTMissed(conversation *model.Conversation, applied *model.AppliedSLA, now time.Time) bool { target := applied.FRTTargetAt // No first reply yet — check if we're past the deadline if conversation.FirstReplyCreatedAt == nil { return !now.Before(*target) } // First reply happened — was it within the threshold? frtActual := time.Unix(*conversation.FirstReplyCreatedAt, 0) return frtActual.After(*target) } // checkNRTMissed checks if the next response time threshold was missed. // Chatwoot: check_next_response_time_threshold // - Skip if first_reply_created_at is nil (covered by FRT) // - Skip if waiting_since is nil (customer not waiting for agent reply) // - Recalculate target: waiting_since + nrt_threshold // - Missed if now > recalculated target func (s *AppliedSlaService) checkNRTMissed(conversation *model.Conversation, applied *model.AppliedSLA, now time.Time) bool { // Chatwoot: "still waiting for first reply, so covered under first response time threshold" if conversation.FirstReplyCreatedAt == nil { return false } // Chatwoot: "Waiting on customer response, no need to check next response time threshold" if conversation.WaitingSince == nil { return false } // Recalculate NRT target from current waiting_since + NRT threshold // Chatwoot recalculates this every evaluation cycle waitingSince := time.Unix(*conversation.WaitingSince, 0) policy, err := s.slaPolicyRepo.FindByID(context.Background(), applied.SlaPolicyID) if err != nil { return false } nrtTarget := waitingSince.Add(time.Duration(policy.NextResponseTimeThreshold) * time.Second) // Update the NRT target timestamp applied.NRTTargetAt = &nrtTarget return !now.Before(nrtTarget) } // checkRTMissed checks if the resolution time threshold was missed. // Chatwoot: check_resolution_time_threshold // - Missed if now > rt_target AND conversation not resolved func (s *AppliedSlaService) checkRTMissed(conversation *model.Conversation, applied *model.AppliedSLA, now time.Time) bool { if conversation.Status == string(model.ConversationStatusResolved) { return false // Already resolved — will be handled by handleHitSla } 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, AccountID: applied.AccountID, ConversationID: applied.ConversationID, InboxID: conversation.InboxID, SlaPolicyID: applied.SlaPolicyID, EventType: eventType, Meta: meta, } if err := s.slaEventRepo.Create(ctx, event); err != nil { 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 if applied.SLAStatus == model.SLAStatusActive { applied.SLAStatus = model.SLAStatusActiveWithMisses } 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("{}")) } 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) // - If active (no misses) → hit func (s *AppliedSlaService) handleHitSla(ctx context.Context, applied *model.AppliedSLA, conversation *model.Conversation) { // Record RT actual timestamp if conversation.ResolvedAt != nil { applied.RTActualAt = conversation.ResolvedAt } // Determine final SLA status based on whether any thresholds were missed switch applied.SLAStatus { case model.SLAStatusActive: // No thresholds were missed → SLA was hit applied.SLAStatus = model.SLAStatusHit applogger.L().Infof("SLA hit for applied_sla %d on conversation %d", applied.ID, applied.ConversationID) case model.SLAStatusActiveWithMisses: // Some thresholds were missed → SLA was missed overall applied.SLAStatus = model.SLAStatusMissed applogger.L().Infof("SLA missed (with threshold misses) for applied_sla %d on conversation %d", applied.ID, applied.ConversationID) } } // RemoveAppliedSla removes the AppliedSLA when SLA policy is removed from conversation. // Chatwoot: when sla_policy_id is set to nil, the applied_sla is destroyed func (s *AppliedSlaService) RemoveAppliedSla(ctx context.Context, conversationID uint) error { applied, err := s.appliedSlaRepo.FindByConversation(ctx, conversationID) if err != nil { // No applied SLA to remove — fine return nil } // Delete all associated SlaEvents first events, err := s.slaEventRepo.FindByAppliedSla(ctx, applied.ID) if err != nil { return fmt.Errorf("find sla events: %w", err) } for _, event := range events { if err := s.slaEventRepo.Delete(ctx, event.ID); err != nil { applogger.L().Errorf("Failed to delete sla event %d: %v", event.ID, err) } } // Delete the AppliedSLA if err := s.appliedSlaRepo.Delete(ctx, applied.ID); err != nil { return fmt.Errorf("delete applied sla: %w", err) } applogger.L().Infof("AppliedSLA removed for conversation %d", conversationID) return nil }