feat(sla): align applied sla reports
This commit is contained in:
@@ -200,6 +200,9 @@ var criticalRoutes = []route{
|
||||
{Method: "DELETE", Path: "/api/v1/accounts/:account_id/agent_capacity_policies/:agent_capacity_policy_id/inbox_limits/:id", Controller: "api/v1/accounts/agent_capacity_policies/inbox_limits#destroy", Source: "routes.rb:126"},
|
||||
{Method: "GET", Path: "/api/v1/accounts/:account_id/csat_survey_responses/", Controller: "api/v1/accounts/csat_survey_responses#index", Source: "routes.rb:219"},
|
||||
{Method: "GET", Path: "/api/v1/accounts/:account_id/csat_survey_responses/metrics", Controller: "api/v1/accounts/csat_survey_responses#metrics", Source: "routes.rb:221"},
|
||||
{Method: "GET", Path: "/api/v1/accounts/:account_id/applied_slas", Controller: "api/v1/accounts/applied_slas#index", Source: "routes.rb:228"},
|
||||
{Method: "GET", Path: "/api/v1/accounts/:account_id/applied_slas/metrics", Controller: "api/v1/accounts/applied_slas#metrics", Source: "routes.rb:230"},
|
||||
{Method: "GET", Path: "/api/v1/accounts/:account_id/applied_slas/download", Controller: "api/v1/accounts/applied_slas#download", Source: "routes.rb:231"},
|
||||
|
||||
{Method: "GET", Path: "/api/v1/accounts/:account_id/captain/preferences/", Controller: "api/v1/accounts/captain/preferences#show", Source: "routes.rb:63"},
|
||||
{Method: "PUT", Path: "/api/v1/accounts/:account_id/captain/preferences/", Controller: "api/v1/accounts/captain/preferences#update", Source: "routes.rb:63"},
|
||||
|
||||
@@ -128,6 +128,7 @@ GET /api/v1/accounts/:account_id/agent_capacity_policies/:id
|
||||
GET /api/v1/accounts/:account_id/agent_capacity_policies/:id/users
|
||||
GET /api/v1/accounts/:account_id/agents
|
||||
GET /api/v1/accounts/:account_id/agents/:agent_id
|
||||
GET /api/v1/accounts/:account_id/applied_slas
|
||||
GET /api/v1/accounts/:account_id/applied_slas/download
|
||||
GET /api/v1/accounts/:account_id/applied_slas/metrics
|
||||
GET /api/v1/accounts/:account_id/assignable_agents
|
||||
@@ -827,4 +828,4 @@ PUT /public/api/v1/csat_survey/:id
|
||||
PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id
|
||||
PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/messages/:message_id
|
||||
PUT /widget/direct_uploads/:upload_uuid
|
||||
TOTAL: 829
|
||||
TOTAL: 830
|
||||
|
||||
@@ -7,7 +7,7 @@ Generated from:
|
||||
|
||||
This report covers tracked frontend-critical Chatwoot routes from `reference/chatwoot/config/routes.rb`, including API v1 account routes, Captain/Copilot, assignment policies, widget/public APIs, and API v2 reports. Ruby is not installed in the workspace, so Chatwoot routes are sourced from static route declarations instead of `bin/rails routes`.
|
||||
|
||||
Summary: 267 exact, 0 method-compatible, 7 parameter-compatible, 0 missing out of 274 tracked critical routes.
|
||||
Summary: 270 exact, 0 method-compatible, 7 parameter-compatible, 0 missing out of 277 tracked critical routes.
|
||||
|
||||
## Missing Critical Routes
|
||||
|
||||
@@ -77,6 +77,9 @@ These routes exist with equivalent method and path shape but different parameter
|
||||
| GET | `/api/v1/accounts/:account_id/agent_capacity_policies/` | `/api/v1/accounts/:account_id/agent_capacity_policies/` | `api/v1/accounts/agent_capacity_policies#index` | `routes.rb:123` | exact |
|
||||
| GET | `/api/v1/accounts/:account_id/agent_capacity_policies/:id` | `/api/v1/accounts/:account_id/agent_capacity_policies/:id` | `api/v1/accounts/agent_capacity_policies#show` | `routes.rb:123` | exact |
|
||||
| GET | `/api/v1/accounts/:account_id/agents` | `/api/v1/accounts/:account_id/agents` | `api/v1/accounts/agents#index` | `routes.rb:59` | exact |
|
||||
| GET | `/api/v1/accounts/:account_id/applied_slas` | `/api/v1/accounts/:account_id/applied_slas` | `api/v1/accounts/applied_slas#index` | `routes.rb:228` | exact |
|
||||
| GET | `/api/v1/accounts/:account_id/applied_slas/download` | `/api/v1/accounts/:account_id/applied_slas/download` | `api/v1/accounts/applied_slas#download` | `routes.rb:231` | exact |
|
||||
| GET | `/api/v1/accounts/:account_id/applied_slas/metrics` | `/api/v1/accounts/:account_id/applied_slas/metrics` | `api/v1/accounts/applied_slas#metrics` | `routes.rb:230` | exact |
|
||||
| GET | `/api/v1/accounts/:account_id/assignable_agents` | `/api/v1/accounts/:account_id/assignable_agents` | `api/v1/accounts/assignable_agents#index` | `routes.rb:104` | exact |
|
||||
| GET | `/api/v1/accounts/:account_id/assignment_policies/` | `/api/v1/accounts/:account_id/assignment_policies/` | `api/v1/accounts/assignment_policies#index` | `routes.rb:306` | exact |
|
||||
| GET | `/api/v1/accounts/:account_id/assignment_policies/:policy_id` | `/api/v1/accounts/:account_id/assignment_policies/:policy_id` | `api/v1/accounts/assignment_policies#show` | `routes.rb:306` | exact |
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -10,6 +15,7 @@ import (
|
||||
"github.com/gochat/gochat/internal/service"
|
||||
applogger "github.com/gochat/gochat/pkg/logger"
|
||||
"github.com/gochat/gochat/pkg/response"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// SlaPolicyHandler handles SLA Policy CRUD + applied SLA metrics/download.
|
||||
@@ -174,7 +180,34 @@ func serializeSlaPolicy(policy *model.SlaPolicy) map[string]any {
|
||||
}
|
||||
}
|
||||
|
||||
// GetAppliedSlaMetrics retrieves SLA metrics for a conversation.
|
||||
// ListAppliedSlas returns the Chatwoot SLA reports table payload.
|
||||
// GET /api/v1/accounts/:account_id/applied_slas
|
||||
func (h *SlaPolicyHandler) ListAppliedSlas(c *gin.Context) {
|
||||
accountID := getAccountID(c)
|
||||
if accountID == 0 {
|
||||
response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "account not identified")
|
||||
return
|
||||
}
|
||||
|
||||
filter := parseAppliedSlaReportFilter(c)
|
||||
page := parseAppliedSlaPage(c)
|
||||
report, svcErr := h.svc.ListAppliedSlaReports(c.Request.Context(), accountID, filter, page)
|
||||
if svcErr != nil {
|
||||
applogger.L().Errorf("List applied SLA reports for account %d: %v", accountID, svcErr)
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"payload": serializeAppliedSlaReportItems(c.Request.Context(), h.svc.DB(), report.AppliedSLAs),
|
||||
"meta": gin.H{
|
||||
"count": report.Count,
|
||||
"current_page": report.CurrentPage,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetAppliedSlaMetrics retrieves Chatwoot SLA report metrics.
|
||||
// GET /api/v1/accounts/:account_id/applied_slas/metrics
|
||||
func (h *SlaPolicyHandler) GetAppliedSlaMetrics(c *gin.Context) {
|
||||
accountID := getAccountID(c)
|
||||
@@ -183,31 +216,17 @@ func (h *SlaPolicyHandler) GetAppliedSlaMetrics(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
conversationIDStr := c.Query("conversation_id")
|
||||
if conversationIDStr == "" {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, "conversation_id is required")
|
||||
return
|
||||
}
|
||||
conversationID, err := strconv.ParseUint(conversationIDStr, 10, 32)
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, "invalid conversation_id")
|
||||
return
|
||||
}
|
||||
|
||||
applied, events, svcErr := h.svc.GetAppliedSlaMetrics(c.Request.Context(), accountID, uint(conversationID))
|
||||
metrics, svcErr := h.svc.GetAppliedSlaReportMetrics(c.Request.Context(), accountID, parseAppliedSlaReportFilter(c))
|
||||
if svcErr != nil {
|
||||
applogger.L().Errorf("Get SLA metrics for account %d, conversation %d: %v", accountID, conversationID, svcErr)
|
||||
applogger.L().Errorf("Get applied SLA report metrics for account %d: %v", accountID, svcErr)
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, gin.H{
|
||||
"applied_sla": applied,
|
||||
"sla_events": events,
|
||||
})
|
||||
c.JSON(http.StatusOK, metrics)
|
||||
}
|
||||
|
||||
// GetAppliedSlaDownload retrieves all applied SLAs for export.
|
||||
// GetAppliedSlaDownload exports missed applied SLAs as Chatwoot CSV.
|
||||
// GET /api/v1/accounts/:account_id/applied_slas/download
|
||||
func (h *SlaPolicyHandler) GetAppliedSlaDownload(c *gin.Context) {
|
||||
accountID := getAccountID(c)
|
||||
@@ -216,14 +235,225 @@ func (h *SlaPolicyHandler) GetAppliedSlaDownload(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
applied, svcErr := h.svc.GetAppliedSlaDownload(c.Request.Context(), accountID)
|
||||
applied, svcErr := h.svc.ListAppliedSlaReportDownload(c.Request.Context(), accountID, parseAppliedSlaReportFilter(c))
|
||||
if svcErr != nil {
|
||||
applogger.L().Errorf("Get SLA download for account %d: %v", accountID, svcErr)
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, applied)
|
||||
c.Header("Content-Type", "text/csv")
|
||||
c.Header("Content-Disposition", "attachment; filename=breached_conversation.csv")
|
||||
c.String(http.StatusOK, buildAppliedSlaCSV(c, h.svc.DB(), applied))
|
||||
}
|
||||
|
||||
func parseAppliedSlaReportFilter(c *gin.Context) service.AppliedSlaReportFilter {
|
||||
return service.AppliedSlaReportFilter{
|
||||
Since: parseUnixQueryTime(c.Query("since")),
|
||||
Until: parseUnixQueryTime(c.Query("until")),
|
||||
InboxID: parseOptionalUintQuery(c.Query("inbox_id")),
|
||||
TeamID: parseOptionalUintQuery(c.Query("team_id")),
|
||||
SlaPolicyID: parseOptionalUintQuery(c.Query("sla_policy_id")),
|
||||
LabelList: c.Query("label_list"),
|
||||
AssignedAgentID: parseOptionalUintQuery(c.Query("assigned_agent_id")),
|
||||
SLAStatus: c.Query("status"),
|
||||
}
|
||||
}
|
||||
|
||||
func parseAppliedSlaPage(c *gin.Context) int {
|
||||
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if err != nil || page < 1 {
|
||||
return 1
|
||||
}
|
||||
return page
|
||||
}
|
||||
|
||||
func parseOptionalUintQuery(raw string) *uint {
|
||||
if raw == "" || raw == "null" || raw == "undefined" {
|
||||
return nil
|
||||
}
|
||||
parsed, err := strconv.ParseUint(raw, 10, 32)
|
||||
if err != nil || parsed == 0 {
|
||||
return nil
|
||||
}
|
||||
value := uint(parsed)
|
||||
return &value
|
||||
}
|
||||
|
||||
func parseUnixQueryTime(raw string) *time.Time {
|
||||
if raw == "" || raw == "0" || raw == "null" || raw == "undefined" {
|
||||
return nil
|
||||
}
|
||||
seconds, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err == nil {
|
||||
t := time.Unix(seconds, 0).UTC()
|
||||
return &t
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, raw); err == nil {
|
||||
return &t
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func serializeAppliedSlaReportItems(ctx context.Context, db *gorm.DB, applied []model.AppliedSLA) []gin.H {
|
||||
payload := make([]gin.H, 0, len(applied))
|
||||
for i := range applied {
|
||||
item := &applied[i]
|
||||
payload = append(payload, gin.H{
|
||||
"applied_sla": serializeAppliedSlaReportApplied(item),
|
||||
"conversation": serializeAppliedSlaReportConversation(ctx, db, item.ConversationID),
|
||||
"sla_events": serializeSlaEvents(item.SlaEvents),
|
||||
})
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func serializeAppliedSlaReportApplied(applied *model.AppliedSLA) gin.H {
|
||||
return gin.H{
|
||||
"id": applied.ID,
|
||||
"sla_id": applied.SlaPolicyID,
|
||||
"sla_status": applied.SLAStatus,
|
||||
"created_at": applied.CreatedAt.Unix(),
|
||||
"updated_at": applied.UpdatedAt.Unix(),
|
||||
"sla_description": applied.SlaPolicy.Description,
|
||||
"sla_name": applied.SlaPolicy.Name,
|
||||
"sla_first_response_time_threshold": applied.SlaPolicy.FirstResponseTimeThreshold,
|
||||
"sla_next_response_time_threshold": applied.SlaPolicy.NextResponseTimeThreshold,
|
||||
"sla_only_during_business_hours": applied.SlaPolicy.OnlyDuringBusinessHours,
|
||||
"sla_resolution_time_threshold": applied.SlaPolicy.ResolutionTimeThreshold,
|
||||
}
|
||||
}
|
||||
|
||||
func serializeAppliedSlaReportConversation(ctx context.Context, db *gorm.DB, conversationID uint) gin.H {
|
||||
conversation := findAppliedSlaConversation(ctx, db, conversationID)
|
||||
if conversation == nil {
|
||||
return gin.H{"id": conversationID, "contact": gin.H{}, "labels": ""}
|
||||
}
|
||||
payload := gin.H{
|
||||
"id": conversationDisplayID(conversation),
|
||||
"contact": gin.H{},
|
||||
"labels": conversation.Labels,
|
||||
}
|
||||
if db == nil {
|
||||
return payload
|
||||
}
|
||||
var contact model.Contact
|
||||
if err := db.WithContext(ctx).First(&contact, conversation.ContactID).Error; err == nil {
|
||||
payload["contact"] = gin.H{"name": contact.Name}
|
||||
}
|
||||
if conversation.AssigneeID != nil && *conversation.AssigneeID != 0 {
|
||||
var user model.User
|
||||
if err := db.WithContext(ctx).First(&user, *conversation.AssigneeID).Error; err == nil {
|
||||
payload["assignee"] = serializeUser(&user, conversation.AccountID)
|
||||
}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func serializeSlaEvents(events []model.SlaEvent) []gin.H {
|
||||
payload := make([]gin.H, 0, len(events))
|
||||
for i := range events {
|
||||
event := events[i]
|
||||
payload = append(payload, gin.H{
|
||||
"id": event.ID,
|
||||
"event_type": event.EventType,
|
||||
"meta": jsonObject(event.Meta),
|
||||
"created_at": event.CreatedAt.Unix(),
|
||||
"updated_at": event.UpdatedAt.Unix(),
|
||||
})
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func buildAppliedSlaCSV(c *gin.Context, db *gorm.DB, applied []model.AppliedSLA) string {
|
||||
var b strings.Builder
|
||||
w := csv.NewWriter(&b)
|
||||
_ = w.Write([]string{"Conversation ID", "SLA policy breached", "Assignee", "Team", "Inbox", "Labels", "Conversation link", "Breached events"})
|
||||
for i := range applied {
|
||||
item := &applied[i]
|
||||
conversation := findAppliedSlaConversation(c.Request.Context(), db, item.ConversationID)
|
||||
row := []string{"", item.SlaPolicy.Name, "", "", "", "", "", appliedSlaEventNames(item.SlaEvents)}
|
||||
if conversation != nil {
|
||||
displayID := conversationDisplayID(conversation)
|
||||
row[0] = strconv.FormatUint(uint64(displayID), 10)
|
||||
row[4] = lookupInboxName(c.Request.Context(), db, conversation.InboxID)
|
||||
row[5] = conversation.Labels
|
||||
row[6] = appliedSlaConversationURL(c, conversation.AccountID, displayID)
|
||||
if conversation.AssigneeID != nil {
|
||||
row[2] = lookupUserName(c.Request.Context(), db, *conversation.AssigneeID)
|
||||
}
|
||||
if conversation.TeamID != nil {
|
||||
row[3] = lookupTeamName(c.Request.Context(), db, *conversation.TeamID)
|
||||
}
|
||||
}
|
||||
_ = w.Write(row)
|
||||
}
|
||||
w.Flush()
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func findAppliedSlaConversation(ctx context.Context, db *gorm.DB, conversationID uint) *model.Conversation {
|
||||
if db == nil || conversationID == 0 {
|
||||
return nil
|
||||
}
|
||||
var conversation model.Conversation
|
||||
if err := db.WithContext(ctx).First(&conversation, conversationID).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
return &conversation
|
||||
}
|
||||
|
||||
func lookupUserName(ctx context.Context, db *gorm.DB, userID uint) string {
|
||||
if db == nil || userID == 0 {
|
||||
return ""
|
||||
}
|
||||
var user model.User
|
||||
if err := db.WithContext(ctx).First(&user, userID).Error; err != nil {
|
||||
return ""
|
||||
}
|
||||
return user.Name
|
||||
}
|
||||
|
||||
func lookupTeamName(ctx context.Context, db *gorm.DB, teamID uint) string {
|
||||
if db == nil || teamID == 0 {
|
||||
return ""
|
||||
}
|
||||
var team model.Team
|
||||
if err := db.WithContext(ctx).First(&team, teamID).Error; err != nil {
|
||||
return ""
|
||||
}
|
||||
return team.Name
|
||||
}
|
||||
|
||||
func lookupInboxName(ctx context.Context, db *gorm.DB, inboxID uint) string {
|
||||
if db == nil || inboxID == 0 {
|
||||
return ""
|
||||
}
|
||||
var inbox model.Inbox
|
||||
if err := db.WithContext(ctx).First(&inbox, inboxID).Error; err != nil {
|
||||
return ""
|
||||
}
|
||||
return inbox.Name
|
||||
}
|
||||
|
||||
func appliedSlaEventNames(events []model.SlaEvent) string {
|
||||
names := make([]string, 0, len(events))
|
||||
for i := range events {
|
||||
names = append(names, string(events[i].EventType))
|
||||
}
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
|
||||
func appliedSlaConversationURL(c *gin.Context, accountID uint, displayID uint) string {
|
||||
path := fmt.Sprintf("/app/accounts/%d/conversations/%d", accountID, displayID)
|
||||
if c.Request == nil || c.Request.Host == "" {
|
||||
return path
|
||||
}
|
||||
scheme := "http"
|
||||
if c.Request.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
return scheme + "://" + c.Request.Host + path
|
||||
}
|
||||
|
||||
// ListInboxes retrieves all inboxes associated with a SLA policy.
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -35,6 +36,11 @@ func setupSlaPolicyHandlerTest(t *testing.T) (*SlaPolicyHandler, *gorm.DB) {
|
||||
&model.SlaPolicyInbox{},
|
||||
&model.AppliedSLA{},
|
||||
&model.SlaEvent{},
|
||||
&model.Inbox{},
|
||||
&model.Contact{},
|
||||
&model.Conversation{},
|
||||
&model.User{},
|
||||
&model.Team{},
|
||||
))
|
||||
t.Cleanup(func() {
|
||||
sqlDB, _ := db.DB()
|
||||
@@ -58,7 +64,9 @@ func setupSlaPolicyHandlerTest(t *testing.T) (*SlaPolicyHandler, *gorm.DB) {
|
||||
func createSlaHandlerTestConversation(db *gorm.DB, accountID uint) *model.Conversation {
|
||||
inbox := &model.Inbox{AccountID: accountID, Name: "test-inbox", ChannelType: "web_widget"}
|
||||
db.Create(inbox)
|
||||
conv := &model.Conversation{AccountID: accountID, InboxID: inbox.ID, Status: "open"}
|
||||
contact := &model.Contact{AccountID: accountID, Name: "SLA Contact"}
|
||||
db.Create(contact)
|
||||
conv := &model.Conversation{AccountID: accountID, InboxID: inbox.ID, ContactID: contact.ID, Status: "open"}
|
||||
db.Create(conv)
|
||||
return conv
|
||||
}
|
||||
@@ -87,6 +95,7 @@ func setupSlaPolicyTestRouter(handler *SlaPolicyHandler) *gin.Engine {
|
||||
rg.GET("/sla_policies/:id/inboxes", handler.ListInboxes)
|
||||
rg.POST("/sla_policies/:id/inboxes", handler.AddInbox)
|
||||
rg.DELETE("/sla_policies/:id/inboxes/:inbox_id", handler.RemoveInbox)
|
||||
rg.GET("/applied_slas", handler.ListAppliedSlas)
|
||||
rg.GET("/applied_slas/metrics", handler.GetAppliedSlaMetrics)
|
||||
rg.GET("/applied_slas/download", handler.GetAppliedSlaDownload)
|
||||
return r
|
||||
@@ -466,63 +475,82 @@ func TestSlaPolicyHandler_ListInboxes_Success(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
// ========== GetAppliedSlaMetrics ==========
|
||||
// ========== Applied SLA reports ==========
|
||||
|
||||
func TestSlaPolicyHandler_GetAppliedSlaMetrics_Success(t *testing.T) {
|
||||
func TestSlaPolicyHandler_ListAppliedSlas_ChatwootPayloadAndFilters(t *testing.T) {
|
||||
handler, db := setupSlaPolicyHandlerTest(t)
|
||||
router := setupSlaPolicyTestRouter(handler)
|
||||
aid := slaHandlerAccountID(db)
|
||||
accountUID := slaHandlerAccountIDUint(db)
|
||||
now := time.Now().UTC()
|
||||
|
||||
svc := service.NewSlaPolicyService(
|
||||
repository.NewSlaPolicyRepo(db),
|
||||
repository.NewAppliedSlaRepo(db),
|
||||
repository.NewSlaEventRepo(db),
|
||||
repository.NewSlaPolicyInboxRepo(db),
|
||||
)
|
||||
policy, _ := svc.Create(nil, accountUID, &service.CreateSlaPolicyRequest{
|
||||
Name: "Test SLA", FirstResponseTimeThreshold: 10, NextResponseTimeThreshold: 20, ResolutionTimeThreshold: 100,
|
||||
})
|
||||
|
||||
applied := &model.AppliedSLA{
|
||||
AccountID: accountUID,
|
||||
ConversationID: 1,
|
||||
SlaPolicyID: policy.ID,
|
||||
}
|
||||
require.NoError(t, db.Create(applied).Error)
|
||||
|
||||
event := &model.SlaEvent{AppliedSlaID: applied.ID, EventType: "frt_reached"}
|
||||
require.NoError(t, db.Create(event).Error)
|
||||
policy, conversation, assignee := seedAppliedSlaReportRecord(t, db, accountUID, model.SLAStatusActiveWithMisses, "vip, urgent", now.Add(-time.Hour))
|
||||
seedAppliedSlaReportRecord(t, db, accountUID, model.SLAStatusHit, "vip", now.Add(-time.Hour))
|
||||
seedAppliedSlaReportRecord(t, db, accountUID, model.SLAStatusMissed, "other", now.Add(-time.Hour))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/api/v1/accounts/"+aid+"/applied_slas/metrics?conversation_id=1", nil)
|
||||
url := "/api/v1/accounts/" + aid + "/applied_slas?" +
|
||||
"since=" + strconv.FormatInt(now.Add(-2*time.Hour).Unix(), 10) +
|
||||
"&until=" + strconv.FormatInt(now.Add(time.Hour).Unix(), 10) +
|
||||
"&inbox_id=" + strconv.FormatUint(uint64(conversation.InboxID), 10) +
|
||||
"&team_id=" + strconv.FormatUint(uint64(*conversation.TeamID), 10) +
|
||||
"&assigned_agent_id=" + strconv.FormatUint(uint64(assignee.ID), 10) +
|
||||
"&sla_policy_id=" + strconv.FormatUint(uint64(policy.ID), 10) +
|
||||
"&label_list=urgent&page=1"
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var got map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got))
|
||||
assert.NotContains(t, got, "success")
|
||||
|
||||
payload := got["payload"].([]interface{})
|
||||
require.Len(t, payload, 1)
|
||||
item := payload[0].(map[string]interface{})
|
||||
applied := item["applied_sla"].(map[string]interface{})
|
||||
assert.Equal(t, "Gold SLA", applied["sla_name"])
|
||||
assert.Equal(t, "active_with_misses", applied["sla_status"])
|
||||
assert.Equal(t, float64(10), applied["sla_first_response_time_threshold"])
|
||||
|
||||
conversationPayload := item["conversation"].(map[string]interface{})
|
||||
assert.Equal(t, float64(*conversation.DisplayID), conversationPayload["id"])
|
||||
assert.Equal(t, "vip, urgent", conversationPayload["labels"])
|
||||
contact := conversationPayload["contact"].(map[string]interface{})
|
||||
assert.Equal(t, "SLA Contact", contact["name"])
|
||||
assigneePayload := conversationPayload["assignee"].(map[string]interface{})
|
||||
assert.Equal(t, "Agent One", assigneePayload["name"])
|
||||
|
||||
events := item["sla_events"].([]interface{})
|
||||
require.Len(t, events, 1)
|
||||
assert.Equal(t, "nrt", events[0].(map[string]interface{})["event_type"])
|
||||
|
||||
meta := got["meta"].(map[string]interface{})
|
||||
assert.Equal(t, float64(1), meta["count"])
|
||||
assert.Equal(t, float64(1), meta["current_page"])
|
||||
}
|
||||
|
||||
func TestSlaPolicyHandler_GetAppliedSlaMetrics_MissingConversationID(t *testing.T) {
|
||||
func TestSlaPolicyHandler_GetAppliedSlaMetrics_ChatwootReportShape(t *testing.T) {
|
||||
handler, db := setupSlaPolicyHandlerTest(t)
|
||||
router := setupSlaPolicyTestRouter(handler)
|
||||
aid := slaHandlerAccountID(db)
|
||||
accountUID := slaHandlerAccountIDUint(db)
|
||||
now := time.Now().UTC()
|
||||
|
||||
seedAppliedSlaReportRecord(t, db, accountUID, model.SLAStatusHit, "vip", now.Add(-time.Hour))
|
||||
seedAppliedSlaReportRecord(t, db, accountUID, model.SLAStatusMissed, "vip", now.Add(-time.Hour))
|
||||
seedAppliedSlaReportRecord(t, db, accountUID, model.SLAStatusActiveWithMisses, "other", now.Add(-time.Hour))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/api/v1/accounts/"+aid+"/applied_slas/metrics", nil)
|
||||
req, _ := http.NewRequest("GET", "/api/v1/accounts/"+aid+"/applied_slas/metrics?label_list=vip", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
}
|
||||
|
||||
func TestSlaPolicyHandler_GetAppliedSlaMetrics_InvalidConversationID(t *testing.T) {
|
||||
handler, db := setupSlaPolicyHandlerTest(t)
|
||||
router := setupSlaPolicyTestRouter(handler)
|
||||
aid := slaHandlerAccountID(db)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/api/v1/accounts/"+aid+"/applied_slas/metrics?conversation_id=notanumber", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var got map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got))
|
||||
assert.Equal(t, float64(2), got["total_applied_slas"])
|
||||
assert.Equal(t, float64(1), got["number_of_sla_misses"])
|
||||
assert.Equal(t, "50.0%", got["hit_rate"])
|
||||
}
|
||||
|
||||
func TestSlaPolicyHandler_GetAppliedSlaMetrics_NoAccountID(t *testing.T) {
|
||||
@@ -546,24 +574,10 @@ func TestSlaPolicyHandler_GetAppliedSlaDownload_Success(t *testing.T) {
|
||||
router := setupSlaPolicyTestRouter(handler)
|
||||
aid := slaHandlerAccountID(db)
|
||||
accountUID := slaHandlerAccountIDUint(db)
|
||||
now := time.Now().UTC()
|
||||
|
||||
svc := service.NewSlaPolicyService(
|
||||
repository.NewSlaPolicyRepo(db),
|
||||
repository.NewAppliedSlaRepo(db),
|
||||
repository.NewSlaEventRepo(db),
|
||||
repository.NewSlaPolicyInboxRepo(db),
|
||||
)
|
||||
policy, _ := svc.Create(nil, accountUID, &service.CreateSlaPolicyRequest{
|
||||
Name: "Test SLA", FirstResponseTimeThreshold: 10, NextResponseTimeThreshold: 20, ResolutionTimeThreshold: 100,
|
||||
})
|
||||
|
||||
applied := &model.AppliedSLA{
|
||||
AccountID: accountUID,
|
||||
SlaPolicyID: policy.ID,
|
||||
ConversationID: createSlaHandlerTestConversation(db, accountUID).ID,
|
||||
SLAStatus: model.SLAStatusActive,
|
||||
}
|
||||
require.NoError(t, db.Create(applied).Error)
|
||||
seedAppliedSlaReportRecord(t, db, accountUID, model.SLAStatusMissed, "vip", now.Add(-time.Hour))
|
||||
seedAppliedSlaReportRecord(t, db, accountUID, model.SLAStatusHit, "vip", now.Add(-time.Hour))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/api/v1/accounts/"+aid+"/applied_slas/download", nil)
|
||||
@@ -571,5 +585,68 @@ func TestSlaPolicyHandler_GetAppliedSlaDownload_Success(t *testing.T) {
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "account_id")
|
||||
assert.Equal(t, "attachment; filename=breached_conversation.csv", w.Header().Get("Content-Disposition"))
|
||||
assert.Contains(t, w.Body.String(), "Conversation ID,SLA policy breached,Assignee,Team,Inbox,Labels,Conversation link,Breached events")
|
||||
assert.Contains(t, w.Body.String(), "Gold SLA")
|
||||
assert.Contains(t, w.Body.String(), "frt")
|
||||
assert.NotContains(t, w.Body.String(), "hit")
|
||||
}
|
||||
|
||||
func seedAppliedSlaReportRecord(t *testing.T, db *gorm.DB, accountID uint, status model.SLAStatus, labels string, createdAt time.Time) (*model.SlaPolicy, *model.Conversation, *model.User) {
|
||||
t.Helper()
|
||||
policy := &model.SlaPolicy{
|
||||
AccountID: accountID,
|
||||
Name: "Gold SLA",
|
||||
Description: "Gold support",
|
||||
FirstResponseTimeThreshold: 10,
|
||||
NextResponseTimeThreshold: 20,
|
||||
ResolutionTimeThreshold: 30,
|
||||
}
|
||||
require.NoError(t, db.Create(policy).Error)
|
||||
|
||||
inbox := &model.Inbox{AccountID: accountID, Name: "Priority", ChannelType: "web_widget", ChannelID: 1}
|
||||
require.NoError(t, db.Create(inbox).Error)
|
||||
contact := &model.Contact{AccountID: accountID, Name: "SLA Contact"}
|
||||
require.NoError(t, db.Create(contact).Error)
|
||||
assignee := &model.User{AccountID: accountID, Name: "Agent One", Email: "agent-" + strconv.FormatInt(time.Now().UnixNano(), 10) + "@example.com", Password: "secret", Role: "agent"}
|
||||
require.NoError(t, db.Create(assignee).Error)
|
||||
team := &model.Team{AccountID: accountID, Name: "Escalation"}
|
||||
require.NoError(t, db.Create(team).Error)
|
||||
displayID := uint(100 + time.Now().UnixNano()%100000)
|
||||
conversation := &model.Conversation{
|
||||
AccountID: accountID,
|
||||
InboxID: inbox.ID,
|
||||
ContactID: contact.ID,
|
||||
AssigneeID: &assignee.ID,
|
||||
TeamID: &team.ID,
|
||||
DisplayID: &displayID,
|
||||
Status: "open",
|
||||
ChannelType: "Channel::WebWidget",
|
||||
Channel: "web_widget",
|
||||
Labels: labels,
|
||||
}
|
||||
require.NoError(t, db.Create(conversation).Error)
|
||||
|
||||
applied := &model.AppliedSLA{AccountID: accountID, ConversationID: conversation.ID, SlaPolicyID: policy.ID, SLAStatus: status}
|
||||
require.NoError(t, db.Create(applied).Error)
|
||||
require.NoError(t, db.Model(applied).Updates(map[string]any{"created_at": createdAt, "updated_at": createdAt}).Error)
|
||||
applied.CreatedAt = createdAt
|
||||
applied.UpdatedAt = createdAt
|
||||
|
||||
if status == model.SLAStatusMissed || status == model.SLAStatusActiveWithMisses {
|
||||
eventType := model.SLAEventFRT
|
||||
if status == model.SLAStatusActiveWithMisses {
|
||||
eventType = model.SLAEventNRT
|
||||
}
|
||||
require.NoError(t, db.Create(&model.SlaEvent{
|
||||
AppliedSlaID: applied.ID,
|
||||
AccountID: accountID,
|
||||
ConversationID: conversation.ID,
|
||||
InboxID: inbox.ID,
|
||||
SlaPolicyID: policy.ID,
|
||||
EventType: eventType,
|
||||
}).Error)
|
||||
}
|
||||
|
||||
return policy, conversation, assignee
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
@@ -57,6 +58,11 @@ type AppliedSlaRepo struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// DB returns the underlying gorm.DB for serializer/report queries.
|
||||
func (r *AppliedSlaRepo) DB() *gorm.DB {
|
||||
return r.db
|
||||
}
|
||||
|
||||
// NewAppliedSlaRepo creates a new AppliedSLA repository.
|
||||
func NewAppliedSlaRepo(db *gorm.DB) *AppliedSlaRepo {
|
||||
return &AppliedSlaRepo{db: db}
|
||||
@@ -77,6 +83,8 @@ func (r *AppliedSlaRepo) FindByConversation(ctx context.Context, conversationID
|
||||
func (r *AppliedSlaRepo) FindByAccount(ctx context.Context, accountID uint) ([]model.AppliedSLA, error) {
|
||||
var applied []model.AppliedSLA
|
||||
if err := r.db.WithContext(ctx).
|
||||
Preload("SlaPolicy").
|
||||
Preload("SlaEvents").
|
||||
Where("account_id = ?", accountID).
|
||||
Find(&applied).Error; err != nil {
|
||||
return nil, err
|
||||
@@ -84,6 +92,93 @@ func (r *AppliedSlaRepo) FindByAccount(ctx context.Context, accountID uint) ([]m
|
||||
return applied, nil
|
||||
}
|
||||
|
||||
// AppliedSlaReportFilter contains Chatwoot applied_slas report filters.
|
||||
type AppliedSlaReportFilter struct {
|
||||
Since *time.Time
|
||||
Until *time.Time
|
||||
InboxID *uint
|
||||
TeamID *uint
|
||||
SlaPolicyID *uint
|
||||
LabelList string
|
||||
AssignedAgentID *uint
|
||||
SLAStatus string
|
||||
}
|
||||
|
||||
// FindReport returns filtered applied SLAs with report preloads.
|
||||
func (r *AppliedSlaRepo) FindReport(ctx context.Context, accountID uint, filter AppliedSlaReportFilter, missedOnly bool, offset, limit int) ([]model.AppliedSLA, int64, error) {
|
||||
base := r.reportQuery(ctx, accountID, filter, missedOnly)
|
||||
|
||||
var total int64
|
||||
if err := base.Session(&gorm.Session{}).Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
query := base.Preload("SlaPolicy").Preload("SlaEvents").Order("applied_slas.created_at DESC")
|
||||
if limit > 0 {
|
||||
query = query.Offset(offset).Limit(limit)
|
||||
}
|
||||
|
||||
var applied []model.AppliedSLA
|
||||
if err := query.Find(&applied).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return applied, total, nil
|
||||
}
|
||||
|
||||
// CountReport returns total and missed counts for Chatwoot SLA report metrics.
|
||||
func (r *AppliedSlaRepo) CountReport(ctx context.Context, accountID uint, filter AppliedSlaReportFilter) (int64, int64, error) {
|
||||
var total int64
|
||||
if err := r.reportQuery(ctx, accountID, filter, false).Count(&total).Error; err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
var missed int64
|
||||
if err := r.reportQuery(ctx, accountID, filter, true).Count(&missed).Error; err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return total, missed, nil
|
||||
}
|
||||
|
||||
func (r *AppliedSlaRepo) reportQuery(ctx context.Context, accountID uint, filter AppliedSlaReportFilter, missedOnly bool) *gorm.DB {
|
||||
query := r.db.WithContext(ctx).Model(&model.AppliedSLA{}).
|
||||
Where("applied_slas.account_id = ?", accountID)
|
||||
|
||||
if filter.Since != nil {
|
||||
query = query.Where("applied_slas.created_at >= ?", *filter.Since)
|
||||
}
|
||||
if filter.Until != nil {
|
||||
query = query.Where("applied_slas.created_at <= ?", *filter.Until)
|
||||
}
|
||||
if filter.SlaPolicyID != nil && *filter.SlaPolicyID != 0 {
|
||||
query = query.Where("applied_slas.sla_policy_id = ?", *filter.SlaPolicyID)
|
||||
}
|
||||
if filter.SLAStatus != "" {
|
||||
query = query.Where("applied_slas.sla_status = ?", filter.SLAStatus)
|
||||
}
|
||||
if missedOnly {
|
||||
query = query.Where("applied_slas.sla_status IN ?", []model.SLAStatus{model.SLAStatusMissed, model.SLAStatusActiveWithMisses})
|
||||
}
|
||||
|
||||
needsConversation := filter.InboxID != nil || filter.TeamID != nil || filter.AssignedAgentID != nil || filter.LabelList != ""
|
||||
if needsConversation {
|
||||
query = query.Joins("JOIN conversations ON conversations.id = applied_slas.conversation_id")
|
||||
if filter.InboxID != nil && *filter.InboxID != 0 {
|
||||
query = query.Where("conversations.inbox_id = ?", *filter.InboxID)
|
||||
}
|
||||
if filter.TeamID != nil && *filter.TeamID != 0 {
|
||||
query = query.Where("conversations.team_id = ?", *filter.TeamID)
|
||||
}
|
||||
if filter.AssignedAgentID != nil && *filter.AssignedAgentID != 0 {
|
||||
query = query.Where("conversations.assignee_id = ?", *filter.AssignedAgentID)
|
||||
}
|
||||
if filter.LabelList != "" {
|
||||
query = query.Where("conversations.labels LIKE ?", "%"+filter.LabelList+"%")
|
||||
}
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
// Create creates a new applied SLA record.
|
||||
func (r *AppliedSlaRepo) Create(ctx context.Context, applied *model.AppliedSLA) error {
|
||||
return r.db.WithContext(ctx).Create(applied).Error
|
||||
|
||||
@@ -1609,6 +1609,7 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) {
|
||||
accountScoped.POST("/sla_policies/:id/inboxes", h.SlaPolicy.AddInbox)
|
||||
accountScoped.DELETE("/sla_policies/:id/inboxes/:inbox_id", h.SlaPolicy.RemoveInbox)
|
||||
// Applied SLA metrics & download
|
||||
accountScoped.GET("/applied_slas", h.SlaPolicy.ListAppliedSlas)
|
||||
accountScoped.GET("/applied_slas/metrics", h.SlaPolicy.GetAppliedSlaMetrics)
|
||||
accountScoped.GET("/applied_slas/download", h.SlaPolicy.GetAppliedSlaDownload)
|
||||
|
||||
|
||||
@@ -3,19 +3,26 @@ 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
|
||||
slaPolicyRepo *repository.SlaPolicyRepo
|
||||
appliedSlaRepo *repository.AppliedSlaRepo
|
||||
slaEventRepo *repository.SlaEventRepo
|
||||
slaPolicyInboxRepo *repository.SlaPolicyInboxRepo
|
||||
}
|
||||
|
||||
@@ -27,13 +34,21 @@ func NewSlaPolicyService(
|
||||
slaPolicyInboxRepo *repository.SlaPolicyInboxRepo,
|
||||
) *SlaPolicyService {
|
||||
return &SlaPolicyService{
|
||||
slaPolicyRepo: slaPolicyRepo,
|
||||
appliedSlaRepo: appliedSlaRepo,
|
||||
slaEventRepo: slaEventRepo,
|
||||
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,
|
||||
@@ -43,8 +58,8 @@ type CreateSlaPolicyRequest struct {
|
||||
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"`
|
||||
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.
|
||||
@@ -61,8 +76,8 @@ type UpdateSlaPolicyRequest struct {
|
||||
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"`
|
||||
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.
|
||||
@@ -70,6 +85,32 @@ 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 {
|
||||
@@ -77,9 +118,9 @@ func (s *SlaPolicyService) Create(ctx context.Context, accountID uint, req *Crea
|
||||
}
|
||||
|
||||
policy := &model.SlaPolicy{
|
||||
AccountID: accountID,
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
AccountID: accountID,
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
FirstResponseTimeThreshold: req.FirstResponseTimeThreshold,
|
||||
NextResponseTimeThreshold: req.NextResponseTimeThreshold,
|
||||
ResolutionTimeThreshold: req.ResolutionTimeThreshold,
|
||||
@@ -189,6 +230,69 @@ func (s *SlaPolicyService) GetAppliedSlaDownload(ctx context.Context, accountID
|
||||
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)
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -31,6 +32,10 @@ func setupSlaPolicyServiceTest(t *testing.T) (*SlaPolicyService, *gorm.DB) {
|
||||
&model.AppliedSLA{},
|
||||
&model.SlaEvent{},
|
||||
&model.Inbox{},
|
||||
&model.Contact{},
|
||||
&model.Conversation{},
|
||||
&model.User{},
|
||||
&model.Team{},
|
||||
), "failed to auto-migrate")
|
||||
|
||||
t.Cleanup(func() {
|
||||
@@ -60,11 +65,11 @@ func TestSlaPolicyService_Create(t *testing.T) {
|
||||
account := createSlaSvcTestAccount(t, db)
|
||||
|
||||
policy, err := svc.Create(context.Background(), account.ID, &CreateSlaPolicyRequest{
|
||||
Name: "Priority SLA",
|
||||
Description: "High priority response times",
|
||||
FirstResponseTimeThreshold: 30,
|
||||
NextResponseTimeThreshold: 60,
|
||||
ResolutionTimeThreshold: 480,
|
||||
Name: "Priority SLA",
|
||||
Description: "High priority response times",
|
||||
FirstResponseTimeThreshold: 30,
|
||||
NextResponseTimeThreshold: 60,
|
||||
ResolutionTimeThreshold: 480,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
@@ -154,19 +159,19 @@ func TestSlaPolicyService_Update(t *testing.T) {
|
||||
account := createSlaSvcTestAccount(t, db)
|
||||
|
||||
policy, _ := svc.Create(context.Background(), account.ID, &CreateSlaPolicyRequest{
|
||||
Name: "Original",
|
||||
Description: "Original desc",
|
||||
FirstResponseTimeThreshold: 10,
|
||||
NextResponseTimeThreshold: 20,
|
||||
ResolutionTimeThreshold: 100,
|
||||
Name: "Original",
|
||||
Description: "Original desc",
|
||||
FirstResponseTimeThreshold: 10,
|
||||
NextResponseTimeThreshold: 20,
|
||||
ResolutionTimeThreshold: 100,
|
||||
})
|
||||
|
||||
updated, err := svc.Update(context.Background(), account.ID, policy.ID, &UpdateSlaPolicyRequest{
|
||||
Name: "Updated SLA",
|
||||
Description: "Updated desc",
|
||||
FirstResponseTimeThreshold: intPtr(45),
|
||||
NextResponseTimeThreshold: intPtr(90),
|
||||
ResolutionTimeThreshold: intPtr(360),
|
||||
Name: "Updated SLA",
|
||||
Description: "Updated desc",
|
||||
FirstResponseTimeThreshold: intPtr(45),
|
||||
NextResponseTimeThreshold: intPtr(90),
|
||||
ResolutionTimeThreshold: intPtr(360),
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
@@ -402,3 +407,85 @@ func TestSlaPolicyService_GetAppliedSlaDownload(t *testing.T) {
|
||||
assert.Len(t, appliedSLAs, 1)
|
||||
assert.Equal(t, applied.ID, appliedSLAs[0].ID)
|
||||
}
|
||||
|
||||
func TestSlaPolicyService_ListAppliedSlaReportsFiltersMisses(t *testing.T) {
|
||||
svc, db := setupSlaPolicyServiceTest(t)
|
||||
account := createSlaSvcTestAccount(t, db)
|
||||
now := time.Now().UTC()
|
||||
|
||||
_, missedConv := seedSlaSvcReportRecord(t, db, account.ID, model.SLAStatusActiveWithMisses, "vip, urgent", now.Add(-time.Hour))
|
||||
seedSlaSvcReportRecord(t, db, account.ID, model.SLAStatusHit, "vip, urgent", now.Add(-time.Hour))
|
||||
seedSlaSvcReportRecord(t, db, account.ID, model.SLAStatusMissed, "other", now.Add(-time.Hour))
|
||||
|
||||
result, err := svc.ListAppliedSlaReports(context.Background(), account.ID, AppliedSlaReportFilter{
|
||||
Since: timePtr(now.Add(-2 * time.Hour)),
|
||||
Until: timePtr(now.Add(time.Hour)),
|
||||
InboxID: &missedConv.InboxID,
|
||||
TeamID: missedConv.TeamID,
|
||||
LabelList: "urgent",
|
||||
}, 1)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), result.Count)
|
||||
require.Len(t, result.AppliedSLAs, 1)
|
||||
assert.Equal(t, model.SLAStatusActiveWithMisses, result.AppliedSLAs[0].SLAStatus)
|
||||
assert.Equal(t, "Gold SLA", result.AppliedSLAs[0].SlaPolicy.Name)
|
||||
require.Len(t, result.AppliedSLAs[0].SlaEvents, 1)
|
||||
}
|
||||
|
||||
func TestSlaPolicyService_GetAppliedSlaReportMetrics(t *testing.T) {
|
||||
svc, db := setupSlaPolicyServiceTest(t)
|
||||
account := createSlaSvcTestAccount(t, db)
|
||||
now := time.Now().UTC()
|
||||
|
||||
seedSlaSvcReportRecord(t, db, account.ID, model.SLAStatusHit, "vip", now.Add(-time.Hour))
|
||||
seedSlaSvcReportRecord(t, db, account.ID, model.SLAStatusMissed, "vip", now.Add(-time.Hour))
|
||||
seedSlaSvcReportRecord(t, db, account.ID, model.SLAStatusActiveWithMisses, "other", now.Add(-time.Hour))
|
||||
|
||||
metrics, err := svc.GetAppliedSlaReportMetrics(context.Background(), account.ID, AppliedSlaReportFilter{LabelList: "vip"})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(2), metrics.TotalAppliedSlas)
|
||||
assert.Equal(t, int64(1), metrics.NumberOfSlaMisses)
|
||||
assert.Equal(t, "50.0%", metrics.HitRate)
|
||||
}
|
||||
|
||||
func TestSlaPolicyService_ListAppliedSlaReportDownload(t *testing.T) {
|
||||
svc, db := setupSlaPolicyServiceTest(t)
|
||||
account := createSlaSvcTestAccount(t, db)
|
||||
now := time.Now().UTC()
|
||||
|
||||
seedSlaSvcReportRecord(t, db, account.ID, model.SLAStatusMissed, "vip", now.Add(-time.Hour))
|
||||
seedSlaSvcReportRecord(t, db, account.ID, model.SLAStatusHit, "vip", now.Add(-time.Hour))
|
||||
|
||||
applied, err := svc.ListAppliedSlaReportDownload(context.Background(), account.ID, AppliedSlaReportFilter{LabelList: "vip"})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, applied, 1)
|
||||
assert.Equal(t, model.SLAStatusMissed, applied[0].SLAStatus)
|
||||
}
|
||||
|
||||
func seedSlaSvcReportRecord(t *testing.T, db *gorm.DB, accountID uint, status model.SLAStatus, labels string, createdAt time.Time) (*model.AppliedSLA, *model.Conversation) {
|
||||
t.Helper()
|
||||
policy := &model.SlaPolicy{AccountID: accountID, Name: "Gold SLA", FirstResponseTimeThreshold: 10, NextResponseTimeThreshold: 20, ResolutionTimeThreshold: 30}
|
||||
require.NoError(t, db.Create(policy).Error)
|
||||
inbox := &model.Inbox{AccountID: accountID, Name: "Priority", ChannelType: "web_widget", ChannelID: 1}
|
||||
require.NoError(t, db.Create(inbox).Error)
|
||||
contact := &model.Contact{AccountID: accountID, Name: "SLA Contact"}
|
||||
require.NoError(t, db.Create(contact).Error)
|
||||
team := &model.Team{AccountID: accountID, Name: "Escalation"}
|
||||
require.NoError(t, db.Create(team).Error)
|
||||
conversation := &model.Conversation{AccountID: accountID, InboxID: inbox.ID, ContactID: contact.ID, TeamID: &team.ID, Status: "open", ChannelType: "Channel::WebWidget", Channel: "web_widget", Labels: labels}
|
||||
require.NoError(t, db.Create(conversation).Error)
|
||||
applied := &model.AppliedSLA{AccountID: accountID, ConversationID: conversation.ID, SlaPolicyID: policy.ID, SLAStatus: status}
|
||||
require.NoError(t, db.Create(applied).Error)
|
||||
require.NoError(t, db.Model(applied).Updates(map[string]any{"created_at": createdAt, "updated_at": createdAt}).Error)
|
||||
if status == model.SLAStatusMissed || status == model.SLAStatusActiveWithMisses {
|
||||
require.NoError(t, db.Create(&model.SlaEvent{AppliedSlaID: applied.ID, AccountID: accountID, ConversationID: conversation.ID, InboxID: inbox.ID, SlaPolicyID: policy.ID, EventType: model.SLAEventFRT}).Error)
|
||||
}
|
||||
return applied, conversation
|
||||
}
|
||||
|
||||
func timePtr(t time.Time) *time.Time {
|
||||
return &t
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user