From 4f85ef11e433a7cf473c9b16041f2359efaf0d37 Mon Sep 17 00:00:00 2001 From: Rogee Date: Fri, 5 Jun 2026 07:45:54 +0800 Subject: [PATCH] feat(sla): align applied sla reports --- cmd/route_parity/main.go | 3 + docs/parity/gochat_routes.txt | 3 +- docs/parity/route_parity.md | 5 +- internal/handler/api/v1/sla_policy_handler.go | 272 ++++++++++++++++-- .../handler/api/v1/sla_policy_handler_test.go | 189 ++++++++---- internal/repository/sla_policy_repo.go | 95 ++++++ internal/router/router.go | 1 + internal/service/sla_policy_service.go | 130 ++++++++- internal/service/sla_policy_service_test.go | 117 +++++++- 9 files changed, 708 insertions(+), 107 deletions(-) diff --git a/cmd/route_parity/main.go b/cmd/route_parity/main.go index 68c1ae31..22ff6a5b 100644 --- a/cmd/route_parity/main.go +++ b/cmd/route_parity/main.go @@ -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"}, diff --git a/docs/parity/gochat_routes.txt b/docs/parity/gochat_routes.txt index 9bd1a74f..21cf1488 100644 --- a/docs/parity/gochat_routes.txt +++ b/docs/parity/gochat_routes.txt @@ -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 diff --git a/docs/parity/route_parity.md b/docs/parity/route_parity.md index 47839de9..e5d0bdf1 100644 --- a/docs/parity/route_parity.md +++ b/docs/parity/route_parity.md @@ -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 | diff --git a/internal/handler/api/v1/sla_policy_handler.go b/internal/handler/api/v1/sla_policy_handler.go index c6649a62..e347e8fa 100644 --- a/internal/handler/api/v1/sla_policy_handler.go +++ b/internal/handler/api/v1/sla_policy_handler.go @@ -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. diff --git a/internal/handler/api/v1/sla_policy_handler_test.go b/internal/handler/api/v1/sla_policy_handler_test.go index feb269b0..bc39c6c3 100644 --- a/internal/handler/api/v1/sla_policy_handler_test.go +++ b/internal/handler/api/v1/sla_policy_handler_test.go @@ -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 } diff --git a/internal/repository/sla_policy_repo.go b/internal/repository/sla_policy_repo.go index 4418900b..edd26855 100644 --- a/internal/repository/sla_policy_repo.go +++ b/internal/repository/sla_policy_repo.go @@ -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 diff --git a/internal/router/router.go b/internal/router/router.go index 765e997a..99d0890e 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -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) diff --git a/internal/service/sla_policy_service.go b/internal/service/sla_policy_service.go index e0a48e82..50875729 100644 --- a/internal/service/sla_policy_service.go +++ b/internal/service/sla_policy_service.go @@ -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) diff --git a/internal/service/sla_policy_service_test.go b/internal/service/sla_policy_service_test.go index 8aaf6faa..0a3ed35b 100644 --- a/internal/service/sla_policy_service_test.go +++ b/internal/service/sla_policy_service_test.go @@ -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 +}