feat(sla): align applied sla reports

This commit is contained in:
2026-06-05 07:45:54 +08:00
parent 0c6cf1dd27
commit 4f85ef11e4
9 changed files with 708 additions and 107 deletions
+251 -21
View File
@@ -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.