package v1 import ( "context" "encoding/csv" "fmt" "net/http" "strconv" "strings" "time" "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/model" "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. // Reference: Chatwoot app/controllers/api/v1/sla_policies_controller.rb type SlaPolicyHandler struct { svc *service.SlaPolicyService auditSvc *service.AuditService } // NewSlaPolicyHandler creates a new SlaPolicy handler. func NewSlaPolicyHandler(svc *service.SlaPolicyService) *SlaPolicyHandler { return &SlaPolicyHandler{svc: svc} } func (h *SlaPolicyHandler) WithAuditService(auditSvc *service.AuditService) *SlaPolicyHandler { h.auditSvc = auditSvc return h } // Create creates a new SLA policy. // POST /api/v1/accounts/:account_id/sla_policies func (h *SlaPolicyHandler) Create(c *gin.Context) { accountID := getAccountID(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "account not identified") return } var wrapper service.SlaPolicyCreateWrapper if err := c.ShouldBindJSON(&wrapper); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } req := wrapper.SlaPolicy policy, svcErr := h.svc.Create(c.Request.Context(), accountID, &req) if svcErr != nil { applogger.L().Errorf("Create SLA policy for account %d: %v", accountID, svcErr) handleServiceError(c, svcErr) return } recordAuditMutation(c, h.auditSvc, auditMutation{ AccountID: accountID, AuditableType: "SlaPolicy", AuditableID: policy.ID, Action: "create", AuditedChanges: serializeSlaPolicy(policy), }) c.JSON(http.StatusOK, gin.H{"payload": serializeSlaPolicy(policy)}) } // Get retrieves a SLA policy by ID. // GET /api/v1/accounts/:account_id/sla_policies/:id func (h *SlaPolicyHandler) Get(c *gin.Context) { accountID := getAccountID(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "account not identified") return } policyID, err := parseUintParam(c, "id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, "invalid policy id") return } policy, svcErr := h.svc.Get(c.Request.Context(), accountID, policyID) if svcErr != nil { applogger.L().Errorf("Get SLA policy %d for account %d: %v", policyID, accountID, svcErr) handleServiceError(c, svcErr) return } c.JSON(http.StatusOK, gin.H{"payload": serializeSlaPolicy(policy)}) } // List retrieves all SLA policies for an account. // GET /api/v1/accounts/:account_id/sla_policies func (h *SlaPolicyHandler) List(c *gin.Context) { accountID := getAccountID(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "account not identified") return } policies, svcErr := h.svc.List(c.Request.Context(), accountID) if svcErr != nil { applogger.L().Errorf("List SLA policies for account %d: %v", accountID, svcErr) handleServiceError(c, svcErr) return } c.JSON(http.StatusOK, gin.H{"payload": serializeSlaPolicies(policies)}) } // Update updates a SLA policy. // PUT /api/v1/accounts/:account_id/sla_policies/:id func (h *SlaPolicyHandler) Update(c *gin.Context) { accountID := getAccountID(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "account not identified") return } policyID, err := parseUintParam(c, "id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, "invalid policy id") return } var wrapper service.SlaPolicyUpdateWrapper if err := c.ShouldBindJSON(&wrapper); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } req := wrapper.SlaPolicy policy, svcErr := h.svc.Update(c.Request.Context(), accountID, policyID, &req) if svcErr != nil { applogger.L().Errorf("Update SLA policy %d for account %d: %v", policyID, accountID, svcErr) handleServiceError(c, svcErr) return } recordAuditMutation(c, h.auditSvc, auditMutation{ AccountID: accountID, AuditableType: "SlaPolicy", AuditableID: policy.ID, Action: "update", AuditedChanges: serializeSlaPolicy(policy), }) c.JSON(http.StatusOK, gin.H{"payload": serializeSlaPolicy(policy)}) } // Delete deletes a SLA policy. // DELETE /api/v1/accounts/:account_id/sla_policies/:id func (h *SlaPolicyHandler) Delete(c *gin.Context) { accountID := getAccountID(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "account not identified") return } policyID, err := parseUintParam(c, "id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, "invalid policy id") return } svcErr := h.svc.Delete(c.Request.Context(), accountID, policyID) if svcErr != nil { applogger.L().Errorf("Delete SLA policy %d for account %d: %v", policyID, accountID, svcErr) handleServiceError(c, svcErr) return } recordAuditMutation(c, h.auditSvc, auditMutation{ AccountID: accountID, AuditableType: "SlaPolicy", AuditableID: policyID, Action: "destroy", AuditedChanges: gin.H{"id": policyID}, }) // Chatwoot returns head :ok (200) on destroy. c.Status(http.StatusOK) } func serializeSlaPolicies(policies []model.SlaPolicy) []map[string]any { payload := make([]map[string]any, 0, len(policies)) for i := range policies { payload = append(payload, serializeSlaPolicy(&policies[i])) } return payload } func serializeSlaPolicy(policy *model.SlaPolicy) map[string]any { if policy == nil { return map[string]any{} } return map[string]any{ "id": policy.ID, "name": policy.Name, "description": policy.Description, "first_response_time_threshold": policy.FirstResponseTimeThreshold, "next_response_time_threshold": policy.NextResponseTimeThreshold, "resolution_time_threshold": policy.ResolutionTimeThreshold, "only_during_business_hours": policy.OnlyDuringBusinessHours, } } // 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) if accountID == 0 { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "account not identified") return } metrics, svcErr := h.svc.GetAppliedSlaReportMetrics(c.Request.Context(), accountID, parseAppliedSlaReportFilter(c)) if svcErr != nil { applogger.L().Errorf("Get applied SLA report metrics for account %d: %v", accountID, svcErr) handleServiceError(c, svcErr) return } c.JSON(http.StatusOK, metrics) } // 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) if accountID == 0 { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "account not identified") return } 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 } 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. // GET /api/v1/accounts/:account_id/sla_policies/:id/inboxes func (h *SlaPolicyHandler) ListInboxes(c *gin.Context) { accountID := getAccountID(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "account not identified") return } policyID, err := parseUintParam(c, "id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, "invalid policy id") return } inboxes, svcErr := h.svc.ListInboxes(c.Request.Context(), accountID, policyID) if svcErr != nil { applogger.L().Errorf("List inboxes for SLA policy %d, account %d: %v", policyID, accountID, svcErr) handleServiceError(c, svcErr) return } response.OK(c, inboxes) } // AddInbox associates an inbox with a SLA policy. // POST /api/v1/accounts/:account_id/sla_policies/:id/inboxes func (h *SlaPolicyHandler) AddInbox(c *gin.Context) { accountID := getAccountID(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "account not identified") return } policyID, err := parseUintParam(c, "id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, "invalid policy id") return } var req struct { InboxID uint `json:"inbox_id" validate:"required"` } if err := c.ShouldBindJSON(&req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } spi, svcErr := h.svc.AddInbox(c.Request.Context(), accountID, policyID, req.InboxID) if svcErr != nil { applogger.L().Errorf("Add inbox %d to SLA policy %d, account %d: %v", req.InboxID, policyID, accountID, svcErr) handleServiceError(c, svcErr) return } response.Created(c, spi) } // RemoveInbox removes an inbox association from a SLA policy. // DELETE /api/v1/accounts/:account_id/sla_policies/:id/inboxes/:inbox_id func (h *SlaPolicyHandler) RemoveInbox(c *gin.Context) { accountID := getAccountID(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "account not identified") return } policyID, err := parseUintParam(c, "id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, "invalid policy id") return } inboxID, err := parseUintParam(c, "inbox_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, "invalid inbox id") return } svcErr := h.svc.RemoveInbox(c.Request.Context(), accountID, policyID, inboxID) if svcErr != nil { applogger.L().Errorf("Remove inbox %d from SLA policy %d, account %d: %v", inboxID, policyID, accountID, svcErr) handleServiceError(c, svcErr) return } response.NoContent(c) }