Files
gochat/backend/internal/handler/api/v1/csat_survey_handler.go
T
rogee aeddedf2a3 Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
Restructure the monorepo into clear top-level directories:
- backend/: Go module root (cmd, internal, pkg, configs, migrations,
  docs/swagger, scripts, tests, go.mod, Makefile, .air.toml)
- deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd
- docs/: project documentation + reports/ (moved from repo root)
- AGENTS.md: new AI coding-agent guide at repo root

Update all references to the new layout:
- Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root)
- docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile,
  env_file ../../.env, volume mounts ../../backend:/app
- deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile
- CI: working-directory: backend for go commands, file deploy/docker/Dockerfile,
  coverage path backend/coverage.out, health_check backend/scripts/
- backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../
- README: architecture tree, quickstart, config paths updated

Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh,
gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to
preserve history. Build, vet, SQLite tests, and docker compose config verified.
2026-07-07 14:44:12 +08:00

555 lines
16 KiB
Go

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/internal/service"
"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
auditSvc *service.AuditService
}
// NewCsatSurveyHandler creates a new CsatSurveyHandler.
func NewCsatSurveyHandler(svc *automation.CsatSurveyService) *CsatSurveyHandler {
return &CsatSurveyHandler{svc: svc}
}
func (h *CsatSurveyHandler) WithAuditService(auditSvc *service.AuditService) *CsatSurveyHandler {
h.auditSvc = auditSvc
return h
}
// 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
}
recordAuditMutation(c, h.auditSvc, auditMutation{
AccountID: resp.AccountID,
AuditableType: "CsatSurveyResponse",
AuditableID: resp.ID,
Action: "update",
AuditedChanges: gin.H{"csat_review_notes": notes},
})
c.JSON(http.StatusOK, h.serializeCsatSurveyResponse(c.Request.Context(), resp))
}
// Update updates CSAT review notes for an account-scoped survey response.
// PATCH /api/v1/accounts/:account_id/csat_survey_responses/:id
func (h *CsatSurveyHandler) Update(c *gin.Context) {
accountID, err := parseUintParam(c, "account_id")
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
}
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
}
if !h.svc.Ready() {
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to update csat survey response")
return
}
if _, err := h.svc.GetByIDForAccount(c.Request.Context(), accountID, id); err != nil {
handleServiceError(c, err)
return
}
notes := body.CsatReviewNotes
if notes == "" {
notes = body.ReviewNotes
}
if err := h.svc.UpdateReviewNotes(c.Request.Context(), id, notes, userID); err != nil {
handleServiceError(c, err)
return
}
resp, err := h.svc.GetByIDForAccount(c.Request.Context(), accountID, id)
if err != nil {
handleServiceError(c, err)
return
}
recordAuditMutation(c, h.auditSvc, auditMutation{
AccountID: accountID,
AuditableType: "CsatSurveyResponse",
AuditableID: resp.ID,
Action: "update",
AuditedChanges: gin.H{"csat_review_notes": notes},
})
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
}
}
// Chatwoot DateRangeHelper applies a range only when both since and until are present.
if sinceRaw, untilRaw := c.Query("since"), c.Query("until"); sinceRaw != "" && untilRaw != "" {
since, sinceErr := parseCsatQueryTime(sinceRaw)
until, untilErr := parseCsatQueryTime(untilRaw)
if sinceErr == nil && untilErr == nil {
filter.Since = &since
filter.Until = &until
}
}
// pagination
if v := c.Query("page"); v != "" {
if n, err := csatParseUintFull(v); err == nil && n > 0 {
filter.Page = 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,
formatCsatCSVTimestamp(r.CreatedAt),
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 formatCsatCSVTimestamp(value time.Time) string {
return value.Format("2006-01-02 15:04:05 MST")
}
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"))
}