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.
555 lines
16 KiB
Go
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"))
|
|
}
|