package v1 import ( "errors" "fmt" "net/http" "strconv" "strings" "time" "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/automation" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/pkg/response" ) // CsatSurveyHandler handles CSAT survey API endpoints. // Reference: Chatwoot CsatSurveyResponsesController — list, metrics, review notes, public access type CsatSurveyHandler struct { svc *automation.CsatSurveyService } // NewCsatSurveyHandler creates a new CsatSurveyHandler. func NewCsatSurveyHandler(svc *automation.CsatSurveyService) *CsatSurveyHandler { return &CsatSurveyHandler{svc: svc} } // List retrieves CSAT survey responses for an account with optional filters. // GET /api/v1/accounts/:account_id/csat_survey_responses func (h *CsatSurveyHandler) List(c *gin.Context) { accountID, err := parseUintParam(c, "account_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } filter := buildCsatFilter(c) responses, total, svcErr := h.svc.ListByAccount(c.Request.Context(), accountID, filter) if svcErr != nil { handleServiceError(c, svcErr) return } c.JSON(http.StatusOK, gin.H{ "csat_survey_responses": responses, "meta": gin.H{ "count": total, "page": filter.Page, "per_page": filter.PageSize, }, }) } // Metrics computes aggregated CSAT statistics for an account. // GET /api/v1/accounts/:account_id/csat_survey_responses/metrics func (h *CsatSurveyHandler) Metrics(c *gin.Context) { accountID, err := parseUintParam(c, "account_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } filter := buildCsatFilter(c) metrics, svcErr := h.svc.Metrics(c.Request.Context(), accountID, filter) if svcErr != nil { handleServiceError(c, svcErr) return } response.OK(c, metrics) } // UpdateReviewNotes updates the review notes on a CSAT survey response. // POST /api/v1/accounts/:account_id/csat_survey_responses/:id/update_review_notes func (h *CsatSurveyHandler) UpdateReviewNotes(c *gin.Context) { id, err := parseUintParam(c, "id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id") return } userID := getUserID(c) var body struct { CsatReviewNotes string `json:"csat_review_notes"` } if err := c.ShouldBindJSON(&body); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } if svcErr := h.svc.UpdateReviewNotes(c.Request.Context(), id, body.CsatReviewNotes, userID); svcErr != nil { handleServiceError(c, svcErr) return } response.OK(c, gin.H{ "id": id, "csat_review_notes": body.CsatReviewNotes, "updated_by_id": userID, }) } // Update updates a CSAT survey response (rating, feedback, review_notes). // PATCH /api/v1/accounts/:account_id/csat_survey_responses/:id func (h *CsatSurveyHandler) Update(c *gin.Context) { id, err := parseUintParam(c, "id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id") return } var body struct { Rating int `json:"rating"` FeedbackMessage string `json:"feedback_message"` CsatReviewNotes string `json:"csat_review_notes"` } if err := c.ShouldBindJSON(&body); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } if !h.svc.Ready() { response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to update csat survey response") return } resp, svcErr := h.svc.Update(c.Request.Context(), id, body.Rating, body.FeedbackMessage, body.CsatReviewNotes) if svcErr != nil { handleServiceError(c, svcErr) return } response.OK(c, resp) } // PublicGet retrieves a CSAT survey response by conversation UUID (no auth required). // GET /public/api/v1/conversations/:conversation_uuid/csats func (h *CsatSurveyHandler) PublicGet(c *gin.Context) { conversationUUID := c.Param("conversation_uuid") if conversationUUID == "" { conversationUUID = c.Param("id") } if conversationUUID == "" { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "conversation_uuid is required") return } resp, svcErr := h.svc.GetPublicSurveyByConversationUUID(c.Request.Context(), conversationUUID) if svcErr != nil { handleServiceError(c, svcErr) return } c.JSON(http.StatusOK, resp) } // PublicUpdate updates a CSAT survey response via conversation UUID (no auth required). // POST /public/api/v1/conversations/:conversation_uuid/csats func (h *CsatSurveyHandler) PublicUpdate(c *gin.Context) { conversationUUID := c.Param("conversation_uuid") if conversationUUID == "" { conversationUUID = c.Param("id") } if conversationUUID == "" { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "conversation_uuid is required") return } var body publicCsatUpdateBody if err := c.ShouldBindJSON(&body); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } resp, svcErr := h.svc.SubmitPublicSurveyByConversationUUID(c.Request.Context(), conversationUUID, body.submittedValues()) if errors.Is(svcErr, automation.ErrCsatSurveyLocked) { c.JSON(http.StatusUnprocessableEntity, gin.H{"error": automation.ErrCsatSurveyLocked.Error()}) return } if svcErr != nil { handleServiceError(c, svcErr) return } c.JSON(http.StatusOK, resp) } type publicCsatUpdateBody struct { Rating int `json:"rating"` FeedbackMessage string `json:"feedback_message"` Message struct { SubmittedValues []map[string]any `json:"submitted_values"` } `json:"message"` } func (b publicCsatUpdateBody) submittedValues() []map[string]any { if len(b.Message.SubmittedValues) > 0 { return b.Message.SubmittedValues } if b.Rating != 0 || b.FeedbackMessage != "" { return []map[string]any{{ "csat_survey_response": map[string]any{ "rating": b.Rating, "feedback_message": b.FeedbackMessage, }, }} } return nil } // buildCsatFilter constructs a CsatListFilter from Gin query parameters. func buildCsatFilter(c *gin.Context) automation.CsatListFilter { filter := automation.CsatListFilter{ Page: 1, PageSize: 25, } // agent_id filter if v := c.Query("agent_id"); v != "" { filter.AgentID = csatUintPtr(csatParseUint(v)) } // since filter (RFC3339 timestamp) if v := c.Query("since"); v != "" { t, err := time.Parse(time.RFC3339, v) if err == nil { filter.Since = &t } } // until filter (RFC3339 timestamp) if v := c.Query("until"); v != "" { t, err := time.Parse(time.RFC3339, v) if err == nil { filter.Until = &t } } // pagination if v := c.Query("page"); v != "" { if n, err := csatParseUintFull(v); err == nil && n > 0 { filter.Page = int(n) } } if v := c.Query("per_page"); v != "" { if n, err := csatParseUintFull(v); err == nil && n > 0 { filter.PageSize = int(n) } } return filter } // csatParseUint parses a query parameter value as uint, returning 0 on failure. func csatParseUint(val string) uint { n, err := csatParseUintFull(val) if err != nil { return 0 } return n } // csatParseUintFull parses a query parameter value as uint with error. func csatParseUintFull(val string) (uint, error) { n, err := strconv.ParseUint(val, 10, 32) if err != nil { return 0, err } return uint(n), nil } // csatUintPtr returns a pointer to the given uint value. func csatUintPtr(v uint) *uint { return &v } // Download exports CSAT survey responses as CSV. // GET /api/v1/accounts/:account_id/csat_survey_responses/download // Reference: Chatwoot csat_survey_responses_controller#download — CSV format matches Chatwoot template func (h *CsatSurveyHandler) Download(c *gin.Context) { accountID, err := parseUintParam(c, "account_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } filter := buildCsatFilter(c) // Download gets all records (no pagination limit) filter.PageSize = 0 responses, _, svcErr := h.svc.ListByAccount(c.Request.Context(), accountID, filter) if svcErr != nil { handleServiceError(c, svcErr) return } // Chatwoot CSV columns: agent_name, rating, feedback, contact_name, contact_email, // contact_phone, conversation_link, recorded_at (+ review_notes for enterprise) csv := "Agent Name,Rating,Feedback Message,Contact Name,Contact Email,Contact Phone Number,Conversation Link,Recorded At\n" for _, r := range responses { agentName := "" if r.AssignedAgentID != nil { // Lookup agent name via DB var agent model.User if err := h.svc.DB().Where("id = ?", *r.AssignedAgentID).First(&agent).Error; err == nil { agentName = fmt.Sprintf("%s (%s)", agent.Name, agent.Email) } } // Lookup contact details var contact model.Contact contactName := "" contactEmail := "" contactPhone := "" if err := h.svc.DB().Where("id = ?", r.ContactID).First(&contact).Error; err == nil { contactName = contact.Name contactEmail = contact.Email contactPhone = contact.PhoneNumber } // Lookup conversation display_id for link var conv model.Conversation conversationLink := "" if err := h.svc.DB().Where("id = ?", r.ConversationID).First(&conv).Error; err == nil { // Chatwoot format: /app/accounts/{account_id}/conversations/{display_id} conversationLink = fmt.Sprintf("/app/accounts/%d/conversations/%d", accountID, conv.DisplayID) } csv += fmt.Sprintf("\"%s\",%d,\"%s\",\"%s\",\"%s\",\"%s\",\"%s\",\"%s\"\n", agentName, r.Rating, sanitizeCSV(r.FeedbackMessage), contactName, contactEmail, contactPhone, conversationLink, r.CreatedAt.Format(time.RFC3339), ) } c.Header("Content-Type", "text/csv") c.Header("Content-Disposition", "attachment; filename=csat_report.csv") c.String(http.StatusOK, csv) } // sanitizeCSV escapes double quotes and newlines for CSV safety. func sanitizeCSV(s string) string { s = strings.ReplaceAll(s, "\"", "\"\"") s = strings.ReplaceAll(s, "\n", " ") return s }