Files
gochat/internal/service/sla_policy_service.go
T

349 lines
12 KiB
Go

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)
}