package service import ( "context" "fmt" "time" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" applogger "github.com/gochat/gochat/pkg/logger" ) // 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, } } // 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) } // Compute target timestamps from policy thresholds + conversation created_at // Chatwoot: threshold is in minutes applied := &model.AppliedSLA{ SlaPolicyID: slaPolicyID, ConversationID: conversationID, AccountID: accountID, SLAStatus: model.SLAStatusActive, } // FRT target: conversation.created_at + first_response_time_threshold (minutes) if policy.FirstResponseTimeThreshold > 0 { frtTarget := conversation.CreatedAt.Add(time.Duration(policy.FirstResponseTimeThreshold) * time.Minute) 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.Minute) applied.NRTTargetAt = &nrtTarget } else { nrtTarget := conversation.CreatedAt.Add(time.Duration(policy.NextResponseTimeThreshold) * time.Minute) applied.NRTTargetAt = &nrtTarget } } // RT target: conversation.created_at + resolution_time_threshold (minutes) if policy.ResolutionTimeThreshold > 0 { rtTarget := conversation.CreatedAt.Add(time.Duration(policy.ResolutionTimeThreshold) * time.Minute) 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) } 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 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 { // 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 { now := time.Now() 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 { now := time.Now() 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.After(*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.Minute) // Update the NRT target timestamp applied.NRTTargetAt = &nrtTarget return now.After(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.After(*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) { // Create SlaEvent record event := &model.SlaEvent{ AppliedSlaID: applied.ID, AccountID: applied.AccountID, ConversationID: applied.ConversationID, InboxID: conversation.InboxID, SlaPolicyID: applied.SlaPolicyID, EventType: eventType, } 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 } // 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) } // 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 }