Files
gochat/internal/service/applied_sla_service.go
T
2026-06-04 15:44:48 +08:00

338 lines
13 KiB
Go

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
}