package service import ( "context" "fmt" "math" "strconv" "strings" "time" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" applogger "github.com/gochat/gochat/pkg/logger" pkgvalidator "github.com/gochat/gochat/pkg/validator" "gorm.io/gorm" ) const appliedSlaReportPageSize = 25 // SlaPolicyService implements business logic for SLA Policy operations. // Reference: Chatwoot app/controllers/api/v1/sla_policies_controller.rb type SlaPolicyService struct { slaPolicyRepo *repository.SlaPolicyRepo appliedSlaRepo *repository.AppliedSlaRepo slaEventRepo *repository.SlaEventRepo slaPolicyInboxRepo *repository.SlaPolicyInboxRepo } // NewSlaPolicyService creates a new SlaPolicy service. func NewSlaPolicyService( slaPolicyRepo *repository.SlaPolicyRepo, appliedSlaRepo *repository.AppliedSlaRepo, slaEventRepo *repository.SlaEventRepo, slaPolicyInboxRepo *repository.SlaPolicyInboxRepo, ) *SlaPolicyService { return &SlaPolicyService{ slaPolicyRepo: slaPolicyRepo, appliedSlaRepo: appliedSlaRepo, slaEventRepo: slaEventRepo, slaPolicyInboxRepo: slaPolicyInboxRepo, } } // DB returns the underlying gorm.DB for Chatwoot report serializers. func (s *SlaPolicyService) DB() *gorm.DB { if s == nil || s.appliedSlaRepo == nil { return nil } return s.appliedSlaRepo.DB() } // CreateSlaPolicyRequest is the DTO for creating a SLA policy. // Reference: Chatwoot sla_policies_controller.rb#permitted_params — nested under "sla_policy" key // Fields: name, description, first_response_time_threshold, next_response_time_threshold, // resolution_time_threshold, only_during_business_hours type CreateSlaPolicyRequest struct { Name string `json:"name" validate:"required"` Description string `json:"description,omitempty"` FirstResponseTimeThreshold int `json:"first_response_time_threshold" validate:"min=0"` NextResponseTimeThreshold int `json:"next_response_time_threshold" validate:"min=0"` ResolutionTimeThreshold int `json:"resolution_time_threshold" validate:"min=0"` OnlyDuringBusinessHours bool `json:"only_during_business_hours"` } // SlaPolicyCreateWrapper wraps CreateSlaPolicyRequest under the "sla_policy" JSON key. // Chatwoot requires params.require(:sla_policy), so the request body must be: // { "sla_policy": { "name": "...", "first_response_time_threshold": 60, ... } } type SlaPolicyCreateWrapper struct { SlaPolicy CreateSlaPolicyRequest `json:"sla_policy"` } // UpdateSlaPolicyRequest is the DTO for updating a SLA policy. // Reference: same permitted_params as Create, all optional for update type UpdateSlaPolicyRequest struct { Name string `json:"name,omitempty"` Description string `json:"description,omitempty"` FirstResponseTimeThreshold *int `json:"first_response_time_threshold,omitempty" validate:"omitempty,min=0"` NextResponseTimeThreshold *int `json:"next_response_time_threshold,omitempty" validate:"omitempty,min=0"` ResolutionTimeThreshold *int `json:"resolution_time_threshold,omitempty" validate:"omitempty,min=0"` OnlyDuringBusinessHours *bool `json:"only_during_business_hours,omitempty"` } // SlaPolicyUpdateWrapper wraps UpdateSlaPolicyRequest under the "sla_policy" JSON key. type SlaPolicyUpdateWrapper struct { SlaPolicy UpdateSlaPolicyRequest `json:"sla_policy"` } // AppliedSlaReportFilter mirrors Chatwoot AppliedSlasController report filters. type AppliedSlaReportFilter struct { Since *time.Time Until *time.Time InboxID *uint TeamID *uint SlaPolicyID *uint LabelList string AssignedAgentID *uint SLAStatus string } // AppliedSlaReportResult is the paginated Chatwoot applied_slas#index result. type AppliedSlaReportResult struct { AppliedSLAs []model.AppliedSLA Count int64 CurrentPage int } // AppliedSlaMetricsResult is the Chatwoot applied_slas#metrics payload. type AppliedSlaMetricsResult struct { TotalAppliedSlas int64 `json:"total_applied_slas"` NumberOfSlaMisses int64 `json:"number_of_sla_misses"` HitRate string `json:"hit_rate"` } // Create creates a new SLA policy for an account. func (s *SlaPolicyService) Create(ctx context.Context, accountID uint, req *CreateSlaPolicyRequest) (*model.SlaPolicy, error) { if err := pkgvalidator.ValidateStruct(req); err != nil { return nil, fmt.Errorf("validation error: %w", err) } policy := &model.SlaPolicy{ AccountID: accountID, Name: req.Name, Description: req.Description, FirstResponseTimeThreshold: req.FirstResponseTimeThreshold, NextResponseTimeThreshold: req.NextResponseTimeThreshold, ResolutionTimeThreshold: req.ResolutionTimeThreshold, OnlyDuringBusinessHours: req.OnlyDuringBusinessHours, } if err := s.slaPolicyRepo.Create(ctx, policy); err != nil { return nil, fmt.Errorf("create sla policy: %w", err) } applogger.L().Infof("SLA policy %d created for account %d", policy.ID, accountID) return policy, nil } // Get retrieves a SLA policy by ID. func (s *SlaPolicyService) Get(ctx context.Context, accountID, policyID uint) (*model.SlaPolicy, error) { policy, err := s.slaPolicyRepo.FindByID(ctx, policyID) 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", policyID, accountID) } return policy, nil } // List retrieves all SLA policies for an account. func (s *SlaPolicyService) List(ctx context.Context, accountID uint) ([]model.SlaPolicy, error) { return s.slaPolicyRepo.FindByAccount(ctx, accountID) } // Update updates a SLA policy. func (s *SlaPolicyService) Update(ctx context.Context, accountID, policyID uint, req *UpdateSlaPolicyRequest) (*model.SlaPolicy, error) { policy, err := s.Get(ctx, accountID, policyID) if err != nil { return nil, err } if req.Name != "" { policy.Name = req.Name } if req.Description != "" { policy.Description = req.Description } if req.FirstResponseTimeThreshold != nil { policy.FirstResponseTimeThreshold = *req.FirstResponseTimeThreshold } if req.NextResponseTimeThreshold != nil { policy.NextResponseTimeThreshold = *req.NextResponseTimeThreshold } if req.ResolutionTimeThreshold != nil { policy.ResolutionTimeThreshold = *req.ResolutionTimeThreshold } if req.OnlyDuringBusinessHours != nil { policy.OnlyDuringBusinessHours = *req.OnlyDuringBusinessHours } if err := s.slaPolicyRepo.Update(ctx, policy); err != nil { return nil, fmt.Errorf("update sla policy: %w", err) } applogger.L().Infof("SLA policy %d updated for account %d", policyID, accountID) return policy, nil } // Delete deletes a SLA policy (and its inbox associations). func (s *SlaPolicyService) Delete(ctx context.Context, accountID, policyID uint) error { policy, err := s.Get(ctx, accountID, policyID) if err != nil { return err } // Remove all inbox associations first if err := s.slaPolicyInboxRepo.DeleteBySlaPolicy(ctx, policy.ID); err != nil { return fmt.Errorf("delete inbox associations: %w", err) } if err := s.slaPolicyRepo.Delete(ctx, policy.ID); err != nil { return fmt.Errorf("delete sla policy: %w", err) } applogger.L().Infof("SLA policy %d deleted for account %d", policyID, accountID) return nil } // GetAppliedSlaMetrics retrieves SLA metrics for a conversation. // Returns the applied SLA and its events. func (s *SlaPolicyService) GetAppliedSlaMetrics(ctx context.Context, accountID, conversationID uint) (*model.AppliedSLA, []model.SlaEvent, error) { applied, err := s.appliedSlaRepo.FindByConversation(ctx, conversationID) if err != nil { return nil, nil, fmt.Errorf("applied sla not found: %w", err) } if applied.AccountID != accountID { return nil, nil, fmt.Errorf("applied sla does not belong to account %d", accountID) } events, err := s.slaEventRepo.FindByAppliedSla(ctx, applied.ID) if err != nil { return nil, nil, fmt.Errorf("sla events not found: %w", err) } return applied, events, nil } // GetAppliedSlaDownload retrieves all applied SLAs for an account (for export/download). func (s *SlaPolicyService) GetAppliedSlaDownload(ctx context.Context, accountID uint) ([]model.AppliedSLA, error) { return s.appliedSlaRepo.FindByAccount(ctx, accountID) } // ListAppliedSlaReports returns missed applied SLAs for the account report table. func (s *SlaPolicyService) ListAppliedSlaReports(ctx context.Context, accountID uint, filter AppliedSlaReportFilter, page int) (*AppliedSlaReportResult, error) { if page < 1 { page = 1 } repoFilter := toRepoAppliedSlaReportFilter(filter) offset := (page - 1) * appliedSlaReportPageSize applied, count, err := s.appliedSlaRepo.FindReport(ctx, accountID, repoFilter, true, offset, appliedSlaReportPageSize) if err != nil { return nil, fmt.Errorf("list applied sla reports: %w", err) } return &AppliedSlaReportResult{AppliedSLAs: applied, Count: count, CurrentPage: page}, nil } // GetAppliedSlaReportMetrics returns metrics for the filtered applied SLA set. func (s *SlaPolicyService) GetAppliedSlaReportMetrics(ctx context.Context, accountID uint, filter AppliedSlaReportFilter) (*AppliedSlaMetricsResult, error) { total, missed, err := s.appliedSlaRepo.CountReport(ctx, accountID, toRepoAppliedSlaReportFilter(filter)) if err != nil { return nil, fmt.Errorf("count applied sla reports: %w", err) } return &AppliedSlaMetricsResult{ TotalAppliedSlas: total, NumberOfSlaMisses: missed, HitRate: formatAppliedSlaHitRate(total, missed), }, nil } // ListAppliedSlaReportDownload returns all filtered missed applied SLAs for CSV export. func (s *SlaPolicyService) ListAppliedSlaReportDownload(ctx context.Context, accountID uint, filter AppliedSlaReportFilter) ([]model.AppliedSLA, error) { applied, _, err := s.appliedSlaRepo.FindReport(ctx, accountID, toRepoAppliedSlaReportFilter(filter), true, 0, 0) if err != nil { return nil, fmt.Errorf("download applied sla reports: %w", err) } return applied, nil } func toRepoAppliedSlaReportFilter(filter AppliedSlaReportFilter) repository.AppliedSlaReportFilter { return repository.AppliedSlaReportFilter{ Since: filter.Since, Until: filter.Until, InboxID: filter.InboxID, TeamID: filter.TeamID, SlaPolicyID: filter.SlaPolicyID, LabelList: filter.LabelList, AssignedAgentID: filter.AssignedAgentID, SLAStatus: filter.SLAStatus, } } func formatAppliedSlaHitRate(total, missed int64) string { if missed == 0 || total == 0 { return "100%" } rate := float64(total-missed) / float64(total) * 100 rate = math.Round(rate*100) / 100 formatted := strconv.FormatFloat(rate, 'f', 2, 64) formatted = strings.TrimRight(formatted, "0") if strings.HasSuffix(formatted, ".") { formatted += "0" } return formatted + "%" } // ListInboxes retrieves all inboxes associated with a SLA policy. func (s *SlaPolicyService) ListInboxes(ctx context.Context, accountID, policyID uint) ([]model.SlaPolicyInbox, error) { policy, err := s.Get(ctx, accountID, policyID) if err != nil { return nil, err } return s.slaPolicyInboxRepo.FindBySlaPolicy(ctx, policy.ID) } // AddInbox associates an inbox with a SLA policy. func (s *SlaPolicyService) AddInbox(ctx context.Context, accountID, policyID, inboxID uint) (*model.SlaPolicyInbox, error) { policy, err := s.Get(ctx, accountID, policyID) if err != nil { return nil, err } spi := &model.SlaPolicyInbox{ SlaPolicyID: policy.ID, InboxID: inboxID, AccountID: accountID, } if err := s.slaPolicyInboxRepo.Create(ctx, spi); err != nil { return nil, fmt.Errorf("add inbox to sla policy: %w", err) } applogger.L().Infof("Inbox %d added to SLA policy %d", inboxID, policyID) return spi, nil } // RemoveInbox removes an inbox association from a SLA policy. func (s *SlaPolicyService) RemoveInbox(ctx context.Context, accountID, policyID, inboxID uint) error { policy, err := s.Get(ctx, accountID, policyID) if err != nil { return err } spInboxes, err := s.slaPolicyInboxRepo.FindBySlaPolicy(ctx, policy.ID) if err != nil { return fmt.Errorf("find inbox associations: %w", err) } for _, spi := range spInboxes { if spi.InboxID == inboxID { if err := s.slaPolicyInboxRepo.Delete(ctx, spi.ID); err != nil { return fmt.Errorf("remove inbox from sla policy: %w", err) } applogger.L().Infof("Inbox %d removed from SLA policy %d", inboxID, policyID) return nil } } return fmt.Errorf("inbox %d not associated with sla policy %d", inboxID, policyID) }