feat(conversations): expose reporting events

This commit is contained in:
2026-06-06 15:39:49 +08:00
parent 495b194411
commit 0631249224
9 changed files with 187 additions and 9 deletions
@@ -811,6 +811,52 @@ func (h *ConversationHandler) InboxAssistant(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"assistant": gin.H{"id": assistant.ID, "name": assistant.Name}})
}
// ReportingEvents returns raw reporting events for a conversation.
// GET /api/v1/accounts/:account_id/conversations/:conversation_id/reporting_events
// Reference: Chatwoot enterprise conversations_controller#reporting_events.
func (h *ConversationHandler) ReportingEvents(c *gin.Context) {
accountID, err := parseUintParam(c, "account_id")
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
}
conversationID, err := parseUintParam(c, "conversation_id")
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid conversation_id")
return
}
events, svcErr := h.conversationSvc.ListReportingEvents(c.Request.Context(), accountID, conversationID)
if svcErr != nil {
handleServiceError(c, svcErr)
return
}
payload := make([]gin.H, 0, len(events))
for i := range events {
payload = append(payload, serializeReportingEvent(events[i]))
}
c.JSON(http.StatusOK, payload)
}
func serializeReportingEvent(event model.ReportingEvent) gin.H {
return gin.H{
"id": event.ID,
"name": event.Name,
"value": event.Value,
"value_in_business_hours": event.ValueInBusinessHours,
"event_start_time": event.EventStartTime,
"event_end_time": event.EventEndTime,
"account_id": event.AccountID,
"inbox_id": event.InboxID,
"user_id": event.UserID,
"conversation_id": event.ConversationID,
"created_at": event.CreatedAt,
"updated_at": event.UpdatedAt,
}
}
// ToggleTyping toggles the typing status for an agent in a conversation.
// POST /api/v1/accounts/:account_id/conversations/:id/toggle_typing_status
// Reference: Chatwoot conversations_controller#toggle_typing_status (member route)
@@ -68,6 +68,7 @@ func (s *ConversationHandlerTestSuite) SetupSuite() {
&model.ContactInbox{},
&model.Conversation{},
&model.ConversationParticipant{},
&model.ReportingEvent{},
&model.Message{},
&model.Attachment{},
&model.InboxMember{},
@@ -122,6 +123,7 @@ func (s *ConversationHandlerTestSuite) SetupSuite() {
conversations.POST("/:conversation_id/transcript", handler.Transcript)
conversations.POST("/:conversation_id/custom_attributes", handler.UpdateCustomAttributes)
conversations.GET("/:conversation_id/attachments", handler.ListAttachments)
conversations.GET("/:conversation_id/reporting_events", handler.ReportingEvents)
conversations.POST("/:conversation_id/toggle_typing", handler.ToggleTyping)
conversations.POST("/:conversation_id/update_last_seen", handler.UpdateLastSeen)
}
@@ -137,6 +139,7 @@ func (s *ConversationHandlerTestSuite) TearDownSuite() {
func (s *ConversationHandlerTestSuite) TearDownTest() {
s.db.Exec("DELETE FROM attachments")
s.db.Exec("DELETE FROM reporting_events")
s.db.Exec("DELETE FROM conversations")
s.db.Exec("DELETE FROM contact_inboxes")
s.db.Exec("DELETE FROM contacts")
@@ -504,6 +507,102 @@ func (s *ConversationHandlerTestSuite) TestListAttachmentsUsesChatwootFixedPageS
assert.Equal(s.T(), float64(newestID), first["id"])
}
func (s *ConversationHandlerTestSuite) TestReportingEventsReturnsRawArrayInCreatedOrder() {
conversationID := s.testConv.ID
baseTime := time.Now().Add(-2 * time.Hour).UTC()
newer := &model.ReportingEvent{
Base: model.Base{CreatedAt: baseTime.Add(time.Hour), UpdatedAt: baseTime.Add(time.Hour)},
AccountID: s.testAccount.ID,
Name: "resolution_time",
Value: 42,
ValueInBusinessHours: 21,
ConversationID: &conversationID,
EventStartTime: baseTime,
EventEndTime: baseTime.Add(time.Minute),
}
older := &model.ReportingEvent{
Base: model.Base{CreatedAt: baseTime, UpdatedAt: baseTime},
AccountID: s.testAccount.ID,
Name: "first_response",
Value: 10,
ValueInBusinessHours: 5,
ConversationID: &conversationID,
EventStartTime: baseTime.Add(-time.Minute),
EventEndTime: baseTime,
}
s.Require().NoError(s.db.Create(newer).Error)
s.Require().NoError(s.db.Create(older).Error)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", fmt.Sprintf("%s/conversations/%d/reporting_events", s.accountURL(), s.testConv.ID), nil)
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code, w.Body.String())
var resp []map[string]any
assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp))
assert.Len(s.T(), resp, 2)
assert.Equal(s.T(), float64(older.ID), resp[0]["id"])
assert.Equal(s.T(), "first_response", resp[0]["name"])
assert.Equal(s.T(), float64(10), resp[0]["value"])
assert.Equal(s.T(), float64(5), resp[0]["value_in_business_hours"])
assert.Equal(s.T(), float64(s.testAccount.ID), resp[0]["account_id"])
assert.Equal(s.T(), float64(s.testConv.ID), resp[0]["conversation_id"])
assert.Contains(s.T(), resp[0], "inbox_id")
assert.Nil(s.T(), resp[0]["inbox_id"])
assert.Contains(s.T(), resp[0], "user_id")
assert.Nil(s.T(), resp[0]["user_id"])
assert.Contains(s.T(), resp[0], "event_start_time")
assert.Contains(s.T(), resp[0], "event_end_time")
assert.NotContains(s.T(), resp[0], "success")
assert.NotContains(s.T(), resp[0], "payload")
assert.NotContains(s.T(), resp[0], "meta")
}
func (s *ConversationHandlerTestSuite) TestReportingEventsUsesDisplayIDAndScopesAccount() {
displayID := uint(880)
s.Require().NoError(s.db.Model(s.testConv).Update("display_id", displayID).Error)
conversationID := s.testConv.ID
event := &model.ReportingEvent{
AccountID: s.testAccount.ID,
Name: "reply_time",
Value: 7,
ValueInBusinessHours: 3,
ConversationID: &conversationID,
EventStartTime: time.Now().Add(-time.Minute).UTC(),
EventEndTime: time.Now().UTC(),
}
s.Require().NoError(s.db.Create(event).Error)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", fmt.Sprintf("%s/conversations/%d/reporting_events", s.accountURL(), displayID), nil)
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code, w.Body.String())
var resp []map[string]any
assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp))
assert.Len(s.T(), resp, 1)
assert.Equal(s.T(), float64(event.ID), resp[0]["id"])
otherAccount := &model.Account{Name: "OtherOrg", Locale: "en", Active: true}
s.Require().NoError(s.db.Create(otherAccount).Error)
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/conversations/%d/reporting_events", otherAccount.ID, displayID), nil)
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusNotFound, w.Code)
}
func (s *ConversationHandlerTestSuite) TestReportingEventsInvalidParams() {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/accounts/abc/conversations/1/reporting_events", nil)
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/conversations/abc/reporting_events", s.testAccount.ID), nil)
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
}
func (s *ConversationHandlerTestSuite) TestToggleTyping_InvalidAccountID() {
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/accounts/abc/conversations/1/toggle_typing", bytes.NewBufferString(`{"typing_status":"on"}`))