Files
gochat/internal/service/applied_sla_service.go
T

512 lines
19 KiB
Go

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
}