package v1 import ( "context" "encoding/csv" "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, _, svcErr := h.svc.ListByAccount(c.Request.Context(), accountID, filter) if svcErr != nil { handleServiceError(c, svcErr) return } payload := make([]map[string]any, 0, len(responses)) for i := range responses { payload = append(payload, h.serializeCsatSurveyResponse(c.Request.Context(), &responses[i])) } c.JSON(http.StatusOK, payload) } // 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 } c.JSON(http.StatusOK, 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"` ReviewNotes string `json:"review_notes"` } if err := c.ShouldBindJSON(&body); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } notes := body.CsatReviewNotes if notes == "" { notes = body.ReviewNotes } if svcErr := h.svc.UpdateReviewNotes(c.Request.Context(), id, notes, userID); svcErr != nil { handleServiceError(c, svcErr) return } resp, svcErr := h.svc.GetByID(c.Request.Context(), id) if svcErr != nil { handleServiceError(c, svcErr) return } c.JSON(http.StatusOK, h.serializeCsatSurveyResponse(c.Request.Context(), resp)) } // 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"` ReviewNotes string `json:"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 } notes := body.CsatReviewNotes if notes == "" { notes = body.ReviewNotes } resp, svcErr := h.svc.Update(c.Request.Context(), id, body.Rating, body.FeedbackMessage, notes) if svcErr != nil { handleServiceError(c, svcErr) return } c.JSON(http.StatusOK, h.serializeCsatSurveyResponse(c.Request.Context(), 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 any `json:"submitted_values"` } `json:"message"` } func (b publicCsatUpdateBody) submittedValues() any { if b.Message.SubmittedValues != nil { 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)) } filter.AgentIDs = csatUintList(c.QueryArray("user_ids[]")) if len(filter.AgentIDs) == 0 { filter.AgentIDs = csatUintList(c.QueryArray("user_ids")) } if len(filter.AgentIDs) == 0 && c.Query("user_ids") != "" { filter.AgentIDs = csatUintCSV(c.Query("user_ids")) } if v := c.Query("inbox_id"); v != "" { filter.InboxID = csatUintPtr(csatParseUint(v)) } if v := c.Query("team_id"); v != "" { filter.TeamID = csatUintPtr(csatParseUint(v)) } if v := c.Query("rating"); v != "" { if n, err := strconv.Atoi(v); err == nil { filter.Rating = &n } } // since filter (Chatwoot frontend sends Unix seconds; keep RFC3339 compatibility for local callers) if v := c.Query("since"); v != "" { t, err := parseCsatQueryTime(v) if err == nil { filter.Since = &t } } // until filter (Chatwoot frontend sends Unix seconds; keep RFC3339 compatibility for local callers) if v := c.Query("until"); v != "" { t, err := parseCsatQueryTime(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 } func csatUintList(values []string) []uint { ids := make([]uint, 0, len(values)) for _, value := range values { if value == "" { continue } ids = append(ids, csatUintCSV(value)...) } return ids } func csatUintCSV(value string) []uint { parts := strings.Split(value, ",") ids := make([]uint, 0, len(parts)) for _, part := range parts { part = strings.TrimSpace(part) if part == "" { continue } if parsed, err := csatParseUintFull(part); err == nil && parsed > 0 { ids = append(ids, parsed) } } return ids } func parseCsatQueryTime(value string) (time.Time, error) { if unix, err := strconv.ParseInt(value, 10, 64); err == nil { return time.Unix(unix, 0), nil } return time.Parse(time.RFC3339, value) } func (h *CsatSurveyHandler) serializeCsatSurveyResponse(ctx context.Context, csat *automation.CsatSurveyResponse) map[string]any { if csat == nil { return nil } conversationID := uint(0) var conversation model.Conversation if err := h.svc.DB().WithContext(ctx).First(&conversation, csat.ConversationID).Error; err == nil { if conversation.DisplayID != nil && *conversation.DisplayID != 0 { conversationID = *conversation.DisplayID } else { conversationID = conversation.ID } } payload := map[string]any{ "id": csat.ID, "rating": csat.Rating, "feedback_message": csat.FeedbackMessage, "csat_review_notes": csat.CsatReviewNotes, "review_notes_updated_at": unixPtr(csat.ReviewNotesUpdatedAt), "account_id": csat.AccountID, "message_id": csat.MessageID, "conversation_id": conversationID, "created_at": csat.CreatedAt.Unix(), } if csat.ReviewNotesUpdatedByID != nil && *csat.ReviewNotesUpdatedByID != 0 { var reviewer model.User if err := h.svc.DB().WithContext(ctx).First(&reviewer, *csat.ReviewNotesUpdatedByID).Error; err == nil { payload["review_notes_updated_by"] = map[string]any{"id": reviewer.ID, "name": reviewer.Name} } } if csat.ContactID != 0 { var contact model.Contact if err := h.svc.DB().WithContext(ctx).First(&contact, csat.ContactID).Error; err == nil { payload["contact"] = serializeContact(&contact) } } if csat.AssignedAgentID != nil && *csat.AssignedAgentID != 0 { var agent model.User if err := h.svc.DB().WithContext(ctx).First(&agent, *csat.AssignedAgentID).Error; err == nil { payload["assigned_agent"] = serializeUser(&agent, csat.AccountID) } } return payload } func unixPtr(value *time.Time) any { if value == nil { return nil } return value.Unix() } // 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 } c.Header("Content-Type", "text/csv") c.Header("Content-Disposition", "attachment; filename=csat_report.csv") writer := csv.NewWriter(c.Writer) header := []string{ "Agent Name", "Rating", "Feedback Comment", "Contact Name", "Contact Email Address", "Contact Phone Number", "Link to the conversation", "Recorded date", "Review Notes", } if err := writer.Write(header); err != nil { response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to write CSV header") return } for _, r := range responses { agentName := "" if r.AssignedAgentID != nil { var agent model.User if err := h.svc.DB().WithContext(c.Request.Context()).Where("id = ?", *r.AssignedAgentID).First(&agent).Error; err == nil { agentName = fmt.Sprintf("%s (%s)", agent.Name, agent.Email) } } var contact model.Contact contactName := "" contactEmail := "" contactPhone := "" if err := h.svc.DB().WithContext(c.Request.Context()).Where("id = ?", r.ContactID).First(&contact).Error; err == nil { contactName = contact.Name contactEmail = contact.Email contactPhone = contact.PhoneNumber } var conv model.Conversation conversationLink := "" if err := h.svc.DB().WithContext(c.Request.Context()).Where("id = ?", r.ConversationID).First(&conv).Error; err == nil { conversationLink = csatConversationURL(c.Request, accountID, &conv) } record := []string{ agentName, strconv.Itoa(r.Rating), r.FeedbackMessage, contactName, contactEmail, contactPhone, conversationLink, r.CreatedAt.Format(time.RFC3339), r.CsatReviewNotes, } if err := writer.Write(record); err != nil { response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to write CSV row") return } } if period := csatReportPeriod(c); period != "" { if err := writer.Write([]string{period}); err != nil { response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to write CSV period") return } } writer.Flush() if err := writer.Error(); err != nil { response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to write CSV") return } } func csatConversationURL(req *http.Request, accountID uint, conversation *model.Conversation) string { if conversation == nil { return "" } displayID := conversation.ID if conversation.DisplayID != nil && *conversation.DisplayID != 0 { displayID = *conversation.DisplayID } path := fmt.Sprintf("/app/accounts/%d/conversations/%d", accountID, displayID) if req == nil || req.Host == "" { return path } scheme := req.Header.Get("X-Forwarded-Proto") if scheme == "" { if req.TLS != nil { scheme = "https" } else { scheme = "http" } } return fmt.Sprintf("%s://%s%s", scheme, req.Host, path) } func csatReportPeriod(c *gin.Context) string { sinceRaw := c.Query("since") untilRaw := c.Query("until") if sinceRaw == "" || untilRaw == "" { return "" } since, err := parseCsatQueryTime(sinceRaw) if err != nil { return "" } until, err := parseCsatQueryTime(untilRaw) if err != nil { return "" } return fmt.Sprintf("Reporting period %s to %s", since.Format("2006-01-02"), until.Format("2006-01-02")) }