Files
gochat/internal/handler/api/v1/csat_survey_handler.go
T

339 lines
9.6 KiB
Go

package v1
import (
"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.GetByConversationUUID(c.Request.Context(), conversationUUID)
if svcErr != nil {
handleServiceError(c, svcErr)
return
}
response.OK(c, 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 struct {
Rating int `json:"rating"`
FeedbackMessage string `json:"feedback_message"`
}
if err := c.ShouldBindJSON(&body); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
return
}
// Find existing response by UUID
resp, svcErr := h.svc.GetByConversationUUID(c.Request.Context(), conversationUUID)
if svcErr != nil {
handleServiceError(c, svcErr)
return
}
if svcErr := h.svc.UpdateResponse(c.Request.Context(), resp.ID, body.Rating, body.FeedbackMessage); svcErr != nil {
handleServiceError(c, svcErr)
return
}
response.OK(c, gin.H{
"id": resp.ID,
"rating": body.Rating,
"feedback_message": body.FeedbackMessage,
})
}
// 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
}