feat(reports): align outgoing message counts

This commit is contained in:
2026-06-07 01:41:09 +08:00
parent f3fda02a9a
commit 2453a84098
5 changed files with 76 additions and 9 deletions
+7 -4
View File
@@ -49,11 +49,11 @@ Hermes task landing checklist:
## Current Baseline
- Current tracking checkpoint: 2026-06-07 inbox-label matrix filter parity checkpoint, prepared as `feat(reports): align inbox label matrix filters`.
- Latest implementation checkpoint: this checkpoint, prepared as `feat(reports): align inbox label matrix filters`.
- Latest documentation/tooling checkpoint: this tracker update records P3.51 inbox-label matrix filter parity plus the committed P3.50/P3.49/P3.48/P3.47/P3.46/P3.45/P3.44/P3.43/P3.42/P3.41/P3.40 reports baseline, lands the known Hermes plan families into this document, and keeps this file as the active follow-up plan that supersedes `.hermes/plans/*`.
- Current tracking checkpoint: 2026-06-07 outgoing messages count builder parity checkpoint, prepared as `feat(reports): align outgoing message counts`.
- Latest implementation checkpoint: this checkpoint, prepared as `feat(reports): align outgoing message counts`.
- Latest documentation/tooling checkpoint: this tracker update records P3.52 outgoing messages count builder parity plus the committed P3.51/P3.50/P3.49/P3.48/P3.47/P3.46/P3.45/P3.44/P3.43/P3.42/P3.41/P3.40 reports baseline, lands the known Hermes plan families into this document, and keeps this file as the active follow-up plan that supersedes `.hermes/plans/*`.
- Plan landing status: complete for the current known Hermes plans and user-confirmed scope. Future work should update this file directly instead of opening a parallel tracker.
- Worktree status at this implementation checkpoint: v2 reports inbox-label matrix now matches inspected Chatwoot `V2::Reports::InboxLabelMatrixBuilder` parameter semantics by honoring optional `since/until`, `inbox_ids`, and `label_ids` filters before building the sorted inbox/label matrix. P3.50 bot metrics builder count parity, P3.49 reports conversation agent-metric parity, P3.48 live grouped team-scope parity, P3.47 grouped error-shape parity, P3.46 summary aggregate parity, P3.45 report bot metric timeseries count strategies, P3.44 live additive rollup parity, P3.43 rollup/backfill timezone parity, P3.42 timeseries timezone parity, P3.41 CSV/download parity, and P3.40 raw JSON/date parity for report, live-report, and summary-report endpoints remain implemented. Profile MFA, Facebook callbacks, account WhatsApp calls, onboarding, notification scoping, API v2 year-in-review, and WhatsApp calling toggle parity remain implemented from previous checkpoints. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack.
- Worktree status at this implementation checkpoint: v2 reports outgoing-message counts now match inspected Chatwoot `V2::Reports::OutgoingMessagesCountBuilder` more closely by returning empty `422` for invalid or blank `group_by` and by grouping label counts by tag name before resolving the account label id, instead of leaking the raw tagging id. P3.51 inbox-label matrix filter parity, P3.50 bot metrics builder count parity, P3.49 reports conversation agent-metric parity, P3.48 live grouped team-scope parity, P3.47 grouped error-shape parity, P3.46 summary aggregate parity, P3.45 report bot metric timeseries count strategies, P3.44 live additive rollup parity, P3.43 rollup/backfill timezone parity, P3.42 timeseries timezone parity, P3.41 CSV/download parity, and P3.40 raw JSON/date parity for report, live-report, and summary-report endpoints remain implemented. Profile MFA, Facebook callbacks, account WhatsApp calls, onboarding, notification scoping, API v2 year-in-review, and WhatsApp calling toggle parity remain implemented from previous checkpoints. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack.
- Next executable implementation checkpoint: continue Phase 2/3 drift audit for deeper report data-source/metric-builder drift or the next reused-frontend mismatch, Phase 6 placeholder audit, or B12 live smoke from fresh reference/smoke evidence.
- `go test ./...` passes when run outside the restricted socket sandbox; focused Facebook callback handler/router/route-parity tests pass in the sandbox.
- Route dump succeeds with `967` registered routes after profile MFA route tracking.
@@ -156,6 +156,7 @@ This table is the shortest authoritative handoff view. If an older lower section
| Priority | Workstream | Current state | Next checkpoint | Commit close rule |
| --- | --- | --- | --- | --- |
| 0 | P3.52 outgoing messages count builder parity | Implemented for reused v2 reports outgoing-message clients: GoChat now follows inspected Chatwoot `Api::V2::Accounts::ReportsController#outgoing_messages_count` and `V2::Reports::OutgoingMessagesCountBuilder` by returning empty `422` for invalid or blank `group_by`, and by grouping label counts by tag name before resolving the current account's label id. This prevents local tag ids from leaking when taggings reference a same-named label outside the account. | Keep in Review; reopen from B12 reports smoke or fresh reference evidence for missing-date `range` behavior, output ordering, nil inbox id serialization, or ActsAsTaggableOn context drift. | Focused Analytics handler and service outgoing-message tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. |
| 0 | P3.51 inbox-label matrix filter parity | Implemented for reused v2 reports matrix clients: GoChat now follows inspected Chatwoot `V2::Reports::InboxLabelMatrixBuilder` by accepting optional `since/until`, `inbox_ids`, and `label_ids`; filtering the inbox and label axes before sorting by name/title; and counting only matching conversation labels whose conversations fall in the optional range and selected inboxes. | Keep in Review; reopen from B12 reports smoke or fresh reference evidence for ActsAsTaggableOn title/name divergence, array param encoding drift, or optional invalid-date controller behavior. | Focused Analytics handler and service matrix tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. |
| 0 | P3.50 bot metrics builder count parity | Implemented for reused v2 reports bot metric clients: GoChat now follows inspected Chatwoot `V2::Reports::BotMetricsBuilder`. `message_count` is scoped to outgoing messages whose `conversation_id` belongs to bot conversations created in the requested range, `resolution_rate` uses distinct resolved conversations after excluding any conversation with a handoff in range, and `handoff_rate` uses distinct non-null handoff conversations. | Keep in Review; reopen from B12 reports smoke or fresh reference evidence for `active_bot?` inbox activation drift, message sender-type filtering drift, or provider/bot-inbox status semantics beyond the inspected builder. | Focused Analytics bot metrics service tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. |
| 0 | P3.49 reports conversation agent-metric parity | Implemented for legacy/reused v2 reports conversation clients: GoChat now follows inspected Chatwoot `Api::V2::Accounts::ReportsController#conversations`, `V2::ReportBuilder#conversation_metrics`, and `V2::ReportBuilder#agent_metrics`. Blank `type` returns empty `422`; `type=account` preserves raw account live metrics; non-account requests return paginated account-user objects with `id`, `name`, `email`, `thumbnail`, `availability`, and a `metric` object containing only `open` and `unattended`, sorted by open count descending. | Keep in Review; reopen from B12 reports smoke or fresh reference evidence for Kaminari pagination metadata assumptions, tie ordering, non-agent `type` scoping with `id`, or exact nil thumbnail/avatar behavior. | Focused Analytics handler and service tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. |
@@ -290,6 +291,7 @@ This ledger records the committed parity checkpoints that future slices should b
| Commit | Scope | Verification summary | Follow-up state |
| --- | --- | --- | --- |
| `feat(reports): align outgoing message counts` | Advances P3.52 outgoing messages count builder parity by matching Chatwoot `Api::V2::Accounts::ReportsController#outgoing_messages_count` and `V2::Reports::OutgoingMessagesCountBuilder`. GoChat now returns empty `422` for invalid or blank `group_by`, and label grouping now counts by tag name then resolves the current account's label id, instead of returning the raw joined tag id. | `go test ./internal/service -run OutgoingMessagesCount -count=1`; `go test ./internal/handler/api/v1 -run AnalyticsHandler -count=1`; full `go test ./...`; `git diff --check`. Route artifacts are unchanged. | P3.52 moves to Review for current outgoing-message builder evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. |
| `feat(reports): align inbox label matrix filters` | Advances P3.51 inbox-label matrix filter parity by matching Chatwoot `V2::Reports::InboxLabelMatrixBuilder`. GoChat now parses optional `since/until`, `inbox_ids`, and `label_ids` on `/reports/inbox_label_matrix`, filters the inbox and label axes before sorting, and counts only labels attached to account conversations in the selected inbox/date range. | `go test ./internal/service -run InboxLabelMatrix -count=1`; `go test ./internal/handler/api/v1 -run AnalyticsHandler -count=1`; full `go test ./...`; `git diff --check`. Route artifacts are unchanged. | P3.51 moves to Review for current matrix builder evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. |
| `feat(reports): align bot metrics builder counts` | Advances P3.50 bot metrics builder parity by matching Chatwoot `V2::Reports::BotMetricsBuilder`. GoChat now derives `message_count` from outgoing messages attached to in-range bot conversations, not every outgoing message in an active-bot inbox; `resolution_rate` uses distinct resolved conversations after excluding handoff conversations; and `handoff_rate` uses distinct non-null handoff conversations. | `go test ./internal/service -run 'AnalyticsBotMetrics\|AnalyticsReportsUsePersisted' -count=1`; full `go test ./...`; `git diff --check`. Route artifacts are unchanged. | P3.50 moves to Review for current bot metrics builder evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. |
| `feat(reports): align conversation agent metrics` | Advances P3.49 reports conversation agent-metric parity by matching Chatwoot `Api::V2::Accounts::ReportsController#conversations` and `V2::ReportBuilder#conversation_metrics/#agent_metrics`. GoChat now returns empty `422` for blank `type`, keeps raw account live metrics for `type=account`, and returns paginated account-user agent objects for non-account requests instead of grouped assignee/team buckets; each agent row includes Chatwoot user fields plus `metric.open` and `metric.unattended`, sorted by open count descending. | `go test ./internal/handler/api/v1 -run AnalyticsHandler -count=1`; `go test ./internal/service -run AnalyticsReportsConversationsAgentMetrics -count=1`; full `go test ./...`; `git diff --check`. Route artifacts are unchanged. | P3.49 moves to Review for current reports conversation evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. |
@@ -2563,3 +2565,4 @@ Verification milestone gates:
- 2026-06-07: P3.49 reports conversation agent-metric checkpoint prepared as `feat(reports): align conversation agent metrics`; audited Chatwoot v2 reports controller `conversations`, `V2::ReportBuilder#conversation_metrics/#agent_metrics`, `ReportHelper#scope`, and reused dashboard `reports.js` conversation metric caller. GoChat `/reports/conversations` now returns empty `422` for blank `type`, keeps raw account live metrics for `type=account`, and returns Chatwoot agent metric rows for non-account requests with paginated account users, availability, thumbnail, and `metric.open/unattended` sorted by open count instead of grouped assignee buckets. Focused Analytics handler and service tests passed; escalated full `go test ./...` and `git diff --check` passed. No route artifacts change.
- 2026-06-07: P3.50 bot metrics builder count checkpoint prepared as `feat(reports): align bot metrics builder counts`; audited Chatwoot `V2::Reports::BotMetricsBuilder`. GoChat `/reports/bot_metrics` now counts outgoing bot messages only through in-range bot conversation IDs, computes bot resolutions as distinct non-null conversations after excluding handoff conversations, and computes handoffs as distinct non-null conversations, matching the builder rate denominators and avoiding active-bot inbox leakage from old conversations. Focused Analytics bot metrics tests passed; escalated full `go test ./...` and `git diff --check` passed. No route artifacts change.
- 2026-06-07: P3.51 inbox-label matrix filter checkpoint prepared as `feat(reports): align inbox label matrix filters`; audited Chatwoot `V2::Reports::InboxLabelMatrixBuilder`. GoChat `/reports/inbox_label_matrix` now parses optional frontend `since/until`, `inbox_ids`, and `label_ids`, filters the returned inbox/label axes, and counts only selected account conversations in the requested date and inbox scope. Focused Analytics handler and service tests passed; escalated full `go test ./...` and `git diff --check` passed. No route artifacts change.
- 2026-06-07: P3.52 outgoing messages count builder checkpoint prepared as `feat(reports): align outgoing message counts`; audited Chatwoot v2 reports controller `outgoing_messages_count` and `V2::Reports::OutgoingMessagesCountBuilder`. GoChat invalid or blank `group_by` now returns empty `422`, and label grouping now follows Chatwoot by grouping counts on tag name and resolving the current account label id rather than returning the raw joined tag id. Focused Analytics handler and service tests passed; escalated full `go test ./...` and `git diff --check` passed. No route artifacts change.
+1 -1
View File
@@ -595,7 +595,7 @@ func (h *AnalyticsHandler) OutgoingMessagesCount(c *gin.Context) {
}
groupBy := c.Query("group_by")
if groupBy != "agent" && groupBy != "team" && groupBy != "inbox" && groupBy != "label" {
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrBadRequest, "invalid group_by")
c.Status(http.StatusUnprocessableEntity)
return
}
@@ -75,6 +75,7 @@ func (s *AnalyticsHandlerTestSuite) SetupSuite() {
accounts.GET("/reports/conversations", s.handler.Conversations)
accounts.GET("/reports/conversations_summary", s.handler.ConversationsSummary)
accounts.GET("/reports/inbox_label_matrix", s.handler.InboxLabelMatrix)
accounts.GET("/reports/outgoing_messages_count", s.handler.OutgoingMessagesCount)
s.router = r
}
@@ -504,6 +505,15 @@ func (s *AnalyticsHandlerTestSuite) TestInboxLabelMatrix_ParsesFrontendFilters()
s.Require().Len(payload["labels"], 1)
}
func (s *AnalyticsHandlerTestSuite) TestOutgoingMessagesCount_InvalidGroupByReturnsEmptyUnprocessableEntity() {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/accounts/1/reports/outgoing_messages_count?since=1735689600&until=1735776000&group_by=bad", nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusUnprocessableEntity, w.Code)
s.Empty(w.Body.String())
}
// ========== Nil service guard ==========
func (s *AnalyticsHandlerTestSuite) TestNilService() {
+25
View File
@@ -212,6 +212,31 @@ func TestAnalyticsInboxLabelMatrixHonorsFiltersAndRange(t *testing.T) {
require.Len(t, payload["labels"], 1)
}
func TestAnalyticsOutgoingMessagesCountLabelUsesAccountLabelName(t *testing.T) {
db, svc, account, inbox, contact, user, _ := setupAnalyticsP513Test(t)
since := time.Date(2026, 6, 8, 0, 0, 0, 0, time.UTC)
until := since.Add(24 * time.Hour)
accountLabel := &model.Tag{AccountID: account.ID, Name: "vip"}
require.NoError(t, db.Create(accountLabel).Error)
otherAccount := &model.Account{Name: "Other"}
require.NoError(t, db.Create(otherAccount).Error)
foreignLabel := &model.Tag{AccountID: otherAccount.ID, Name: "vip"}
require.NoError(t, db.Create(foreignLabel).Error)
conversation := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, AssigneeID: &user.ID, ChannelType: inbox.ChannelType, Channel: inbox.ChannelType}
require.NoError(t, db.Create(conversation).Error)
senderID := user.ID
require.NoError(t, db.Create(&model.Message{Base: model.Base{CreatedAt: since.Add(time.Hour)}, AccountID: account.ID, InboxID: inbox.ID, ConversationID: conversation.ID, SenderID: &senderID, SenderType: "User", MessageType: string(model.MessageTypeOutgoing), Content: "hello"}).Error)
require.NoError(t, db.Create(&model.ConversationLabel{AccountID: account.ID, ConversationID: conversation.ID, TagID: foreignLabel.ID}).Error)
result, err := svc.GetOutgoingMessagesCountGrouped(context.Background(), account.ID, since, until, "label")
require.NoError(t, err)
rows := result.([]map[string]interface{})
require.Len(t, rows, 1)
assert.Equal(t, accountLabel.ID, rows[0]["id"])
assert.Equal(t, "vip", rows[0]["name"])
assert.Equal(t, int64(1), rows[0]["outgoing_messages_count"])
}
func TestAnalyticsTimeseriesAndRollupWorker(t *testing.T) {
db, svc, account, inbox, contact, user, _ := setupAnalyticsP513Test(t)
since := time.Date(2026, 6, 2, 0, 0, 0, 0, time.UTC)
+33 -4
View File
@@ -979,15 +979,44 @@ func (s *AnalyticsService) outgoingMessagesByInbox(ctx context.Context, db *gorm
}
func (s *AnalyticsService) outgoingMessagesByLabel(ctx context.Context, db *gorm.DB, accountID uint, since, until time.Time) ([]map[string]interface{}, error) {
var rows []outgoingCountRow
type labelRow struct {
Name string
Count int64
}
var rows []labelRow
err := db.WithContext(ctx).Table("messages").
Select("tags.id AS id, tags.name AS name, COUNT(*) AS count").
Select("tags.name AS name, COUNT(*) AS count").
Joins("INNER JOIN conversations ON conversations.id = messages.conversation_id").
Joins("INNER JOIN conversation_labels ON conversation_labels.conversation_id = conversations.id").
Joins("INNER JOIN tags ON tags.id = conversation_labels.tag_id").
Where("messages.account_id = ? AND messages.message_type = ? AND messages.created_at >= ? AND messages.created_at < ?", accountID, string(model.MessageTypeOutgoing), since, until).
Group("tags.id, tags.name").Scan(&rows).Error
return outgoingRows(rows, "label"), err
Group("tags.name").Scan(&rows).Error
if err != nil {
return nil, err
}
names := make([]string, 0, len(rows))
for _, row := range rows {
names = append(names, row.Name)
}
var labels []model.Tag
if len(names) > 0 {
if err := db.WithContext(ctx).Where("account_id = ? AND name IN ?", accountID, names).Find(&labels).Error; err != nil {
return nil, err
}
}
labelIDs := map[string]uint{}
for _, label := range labels {
labelIDs[label.Name] = label.ID
}
result := make([]map[string]interface{}, 0, len(rows))
for _, row := range rows {
var id interface{}
if labelID, ok := labelIDs[row.Name]; ok {
id = labelID
}
result = append(result, map[string]interface{}{"id": id, "name": row.Name, "outgoing_messages_count": row.Count})
}
return result, nil
}
func outgoingRows(rows []outgoingCountRow, _ string) []map[string]interface{} {