This commit is contained in:
2026-06-09 18:03:16 +08:00
parent f45fbfd5f0
commit ac7f351308
29 changed files with 4867 additions and 254 deletions
+37 -26
View File
@@ -123,18 +123,22 @@ func (h *CsatSurveyHandler) UpdateReviewNotes(c *gin.Context) {
c.JSON(http.StatusOK, h.serializeCsatSurveyResponse(c.Request.Context(), resp))
}
// Update updates a CSAT survey response (rating, feedback, review_notes).
// 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 {
Rating int `json:"rating"`
FeedbackMessage string `json:"feedback_message"`
CsatReviewNotes string `json:"csat_review_notes"`
ReviewNotes string `json:"review_notes"`
}
@@ -146,17 +150,32 @@ func (h *CsatSurveyHandler) Update(c *gin.Context) {
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
}
resp, svcErr := h.svc.Update(c.Request.Context(), id, body.Rating, body.FeedbackMessage, notes)
if svcErr != nil {
handleServiceError(c, svcErr)
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))
}
@@ -265,19 +284,13 @@ func buildCsatFilter(c *gin.Context) automation.CsatListFilter {
}
}
// 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
// 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
}
}
@@ -287,12 +300,6 @@ func buildCsatFilter(c *gin.Context) automation.CsatListFilter {
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
}
@@ -479,7 +486,7 @@ func (h *CsatSurveyHandler) Download(c *gin.Context) {
contactEmail,
contactPhone,
conversationLink,
r.CreatedAt.Format(time.RFC3339),
formatCsatCSVTimestamp(r.CreatedAt),
r.CsatReviewNotes,
}
if err := writer.Write(record); err != nil {
@@ -502,6 +509,10 @@ func (h *CsatSurveyHandler) Download(c *gin.Context) {
}
}
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 ""
@@ -148,6 +148,62 @@ func (s *CsatSurveyHandlerTestSuite) TestList_ChatwootPayloadAndFilters() {
assert.Equal(s.T(), reviewer.Name, reviewerPayload["name"])
}
func (s *CsatSurveyHandlerTestSuite) TestList_IgnoresPerPageAndUsesChatwootFixedPageSize() {
createdAt := time.Now().Add(-2 * time.Hour).Truncate(time.Second)
_, _, contact, conversation, _ := s.seedAccountCsatResponseGraph(createdAt, 5)
for i := 0; i < 29; i++ {
response := &automation.CsatSurveyResponse{
AccountID: s.account.ID,
ConversationID: conversation.ID,
ContactID: contact.ID,
Rating: 4,
}
s.Require().NoError(s.db.Create(response).Error)
recordedAt := createdAt.Add(time.Duration(i+1) * time.Minute)
s.Require().NoError(s.db.Model(response).Updates(map[string]any{"created_at": recordedAt, "updated_at": recordedAt}).Error)
}
r := gin.New()
r.GET("/api/v1/accounts/:account_id/csat_survey_responses", s.handler.List)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/csat_survey_responses?page=1&per_page=5", s.account.ID), nil)
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
var payload []map[string]any
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload))
s.Require().Len(payload, 25)
}
func (s *CsatSurveyHandlerTestSuite) TestList_UsesChatwootDateRangeBoundary() {
baseTime := time.Now().Add(-2 * time.Hour).Truncate(time.Second)
_, _, contact, conversation, _ := s.seedAccountCsatResponseGraph(baseTime.Add(30*time.Minute), 5)
onUntil := &automation.CsatSurveyResponse{AccountID: s.account.ID, ConversationID: conversation.ID, ContactID: contact.ID, Rating: 4}
s.Require().NoError(s.db.Create(onUntil).Error)
s.Require().NoError(s.db.Model(onUntil).Updates(map[string]any{"created_at": baseTime.Add(time.Hour), "updated_at": baseTime.Add(time.Hour)}).Error)
r := gin.New()
r.GET("/api/v1/accounts/:account_id/csat_survey_responses", s.handler.List)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/csat_survey_responses?since=%d", s.account.ID, baseTime.Add(30*time.Minute).Unix()), nil)
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
var payload []map[string]any
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload))
s.Require().Len(payload, 2)
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/csat_survey_responses?since=%d&until=%d", s.account.ID, baseTime.Unix(), baseTime.Add(time.Hour).Unix()), nil)
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
payload = nil
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload))
s.Require().Len(payload, 1)
assert.Equal(s.T(), float64(5), payload[0]["rating"])
}
func (s *CsatSurveyHandlerTestSuite) TestMetrics_ChatwootPayloadAndFilters() {
createdAt := time.Now().Add(-2 * time.Hour).Truncate(time.Second)
agent, _, _, conversation, _ := s.seedAccountCsatResponseGraph(createdAt, 5)
@@ -206,11 +262,16 @@ func (s *CsatSurveyHandlerTestSuite) TestDownload_ChatwootCSVAndFilters() {
assert.Equal(s.T(), contact.Email, rows[1][4])
assert.Equal(s.T(), contact.PhoneNumber, rows[1][5])
assert.Equal(s.T(), fmt.Sprintf("https://app.example.test/app/accounts/%d/conversations/42", s.account.ID), rows[1][6])
assert.Equal(s.T(), createdAt.Format(time.RFC3339), rows[1][7])
assert.Equal(s.T(), "2026-06-05 10:30:00 UTC", rows[1][7])
assert.Equal(s.T(), "Needs follow up", rows[1][8])
assert.Equal(s.T(), "Reporting period 1970-01-01 to 3000-01-01", rows[2][0])
}
func (s *CsatSurveyHandlerTestSuite) TestDownload_FormatsRecordedAtLikeChatwootCSV() {
recordedAt := time.Date(2026, 6, 5, 10, 30, 0, 0, time.UTC)
assert.Equal(s.T(), "2026-06-05 10:30:00 UTC", formatCsatCSVTimestamp(recordedAt))
}
func (s *CsatSurveyHandlerTestSuite) TestUpdateReviewNotes_Success() {
_, reviewer, _, _, _ := s.seedAccountCsatResponseGraph(time.Now().Add(-time.Hour), 5)
var survey automation.CsatSurveyResponse
@@ -237,22 +298,49 @@ func (s *CsatSurveyHandlerTestSuite) TestUpdateReviewNotes_Success() {
}
func (s *CsatSurveyHandlerTestSuite) TestUpdate_Success() {
survey := &automation.CsatSurveyResponse{AccountID: s.account.ID, ConversationID: 1, Rating: 5}
_, reviewer, _, _, _ := s.seedAccountCsatResponseGraph(time.Now().Add(-time.Hour), 5)
var survey automation.CsatSurveyResponse
s.Require().NoError(s.db.First(&survey).Error)
originalRating := survey.Rating
r := gin.New()
r.PATCH("/api/v1/accounts/:account_id/csat_survey_responses/:id", func(c *gin.Context) {
c.Set("user_id", float64(reviewer.ID))
s.handler.Update(c)
})
w := httptest.NewRecorder()
body := `{"csat_review_notes":"updated notes","rating":1,"feedback_message":"ignored"}`
req, _ := http.NewRequest("PATCH", fmt.Sprintf("/api/v1/accounts/%d/csat_survey_responses/%d", s.account.ID, survey.ID), bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
var payload map[string]any
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload))
assert.Equal(s.T(), "updated notes", payload["csat_review_notes"])
assert.Equal(s.T(), float64(originalRating), payload["rating"])
reviewerPayload := payload["review_notes_updated_by"].(map[string]any)
assert.Equal(s.T(), reviewer.Name, reviewerPayload["name"])
}
func (s *CsatSurveyHandlerTestSuite) TestUpdate_NotFoundAcrossAccountScope() {
survey := &automation.CsatSurveyResponse{AccountID: s.account.ID + 1, ConversationID: 1, ContactID: 1, Rating: 5}
s.Require().NoError(s.db.Create(survey).Error)
r := gin.New()
r.PUT("/api/v1/accounts/:account_id/csats/:id", func(c *gin.Context) {
r.PATCH("/api/v1/accounts/:account_id/csat_survey_responses/:id", func(c *gin.Context) {
c.Set("user_id", float64(1))
s.handler.Update(c)
})
w := httptest.NewRecorder()
body := `{"review_notes":"updated notes"}`
req, _ := http.NewRequest("PUT", fmt.Sprintf("/api/v1/accounts/%d/csats/%d", s.account.ID, survey.ID), bytes.NewBufferString(body))
body := `{"csat_review_notes":"updated notes"}`
req, _ := http.NewRequest("PATCH", fmt.Sprintf("/api/v1/accounts/%d/csat_survey_responses/%d", s.account.ID, survey.ID), bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
assert.Equal(s.T(), http.StatusNotFound, w.Code)
}
func (s *CsatSurveyHandlerTestSuite) TestPublicCsatShowAndUpdate_Success() {
@@ -16,6 +16,7 @@ import (
"gorm.io/gorm/logger"
"github.com/gochat/gochat/internal/model"
channelmodel "github.com/gochat/gochat/internal/model/channel"
"github.com/gochat/gochat/internal/repository"
"github.com/gochat/gochat/internal/service"
)
@@ -33,7 +34,7 @@ func TestInboxHandler_ChatwootSerializerParity(t *testing.T) {
_ = sqlDB.Close()
}
})
require.NoError(t, db.AutoMigrate(&model.Account{}, &model.Inbox{}, &model.WorkingHour{}))
require.NoError(t, db.AutoMigrate(&model.Account{}, &model.Inbox{}, &model.WorkingHour{}, &channelmodel.ChannelAPI{}))
account := &model.Account{Name: "Inbox Parity", Locale: "en", Active: true}
require.NoError(t, db.Create(account).Error)
@@ -123,7 +124,7 @@ func TestInboxHandler_ChatwootCreateUpdateRequestBinding(t *testing.T) {
_ = sqlDB.Close()
}
})
require.NoError(t, db.AutoMigrate(&model.Account{}, &model.Inbox{}, &model.WorkingHour{}))
require.NoError(t, db.AutoMigrate(&model.Account{}, &model.Inbox{}, &model.WorkingHour{}, &channelmodel.ChannelAPI{}))
account := &model.Account{Name: "Inbox Binding", Locale: "en", Active: true}
require.NoError(t, db.Create(account).Error)
@@ -256,7 +257,7 @@ func TestInboxHandler_ChatwootCreateRejectsAccountInboxLimit(t *testing.T) {
_ = sqlDB.Close()
}
})
require.NoError(t, db.AutoMigrate(&model.Account{}, &model.Inbox{}, &model.WorkingHour{}))
require.NoError(t, db.AutoMigrate(&model.Account{}, &model.Inbox{}, &model.WorkingHour{}, &channelmodel.ChannelAPI{}))
account := &model.Account{Name: "Inbox Limit", Locale: "en", Active: true, InboxLimit: 1}
require.NoError(t, db.Create(account).Error)
@@ -290,7 +291,7 @@ func TestInboxHandler_ChatwootChannelSpecificConfigDepth(t *testing.T) {
_ = sqlDB.Close()
}
})
require.NoError(t, db.AutoMigrate(&model.Account{}, &model.Inbox{}, &model.WorkingHour{}))
require.NoError(t, db.AutoMigrate(&model.Account{}, &model.Inbox{}, &model.WorkingHour{}, &channelmodel.ChannelAPI{}))
account := &model.Account{Name: "Inbox Channel Depth", Locale: "en", Active: true}
require.NoError(t, db.Create(account).Error)