512 lines
19 KiB
Go
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
|
|
}
|