338 lines
13 KiB
Go
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
|
|
} |