349 lines
12 KiB
Go
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)
|
|
}
|