From ab130d5b2edf2bc2fb3454e0bb97453e3a395911 Mon Sep 17 00:00:00 2001 From: Rogee Date: Sat, 6 Jun 2026 23:25:58 +0800 Subject: [PATCH] feat(reports): align v2 csv downloads --- docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md | 13 +- internal/handler/api/v1/analytics_handler.go | 149 ++++++- .../handler/api/v1/analytics_handler_test.go | 154 ++++++- internal/service/analytics_csv.go | 422 ++++++++++++++++++ 4 files changed, 719 insertions(+), 19 deletions(-) create mode 100644 internal/service/analytics_csv.go diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 093ddc36..be47b246 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -49,12 +49,12 @@ Hermes task landing checklist: ## Current Baseline -- Current tracking checkpoint: 2026-06-06 API v2 reports envelope/date-parameter parity checkpoint, prepared as `feat(reports): align v2 report envelopes`. -- Latest implementation checkpoint: this checkpoint, prepared as `feat(reports): align v2 report envelopes`. -- Latest documentation/tooling checkpoint: this tracker update records P3.40 API v2 report response/date parity plus the committed P3.39 baseline and landed parity tracker history; this document is the active follow-up plan and supersedes `.hermes/plans/*`. +- Current tracking checkpoint: 2026-06-06 API v2 reports CSV/download parity checkpoint, prepared as `feat(reports): align v2 csv downloads`. +- Latest implementation checkpoint: this checkpoint, prepared as `feat(reports): align v2 csv downloads`. +- Latest documentation/tooling checkpoint: this tracker update records P3.41 API v2 report CSV/download parity plus the committed P3.40/P3.39 baseline and landed parity tracker history; this document is the active follow-up plan and 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: API v2 reports from `reference/chatwoot/config/routes.rb:481-509`, `Api::V2::Accounts::ReportsController`, `SummaryReportsController`, `LiveReportsController`, and reused dashboard `reports.js`, `summaryReports.js`, and `liveReports.js` are aligned for frontend date params and raw JSON envelopes. Report and summary-report handlers now accept Chatwoot Unix-second `since/until` params while preserving RFC3339 compatibility, and API v2 reports/live_reports/summary_reports return raw JSON instead of the local `{ success, data }` envelope. 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, Phase 6 placeholder audit, or B12 live smoke from fresh reference/smoke evidence. +- Worktree status at this implementation checkpoint: API v2 reports from `reference/chatwoot/config/routes.rb:481-509`, `Api::V2::Accounts::ReportsController`, CSV templates under `app/views/api/v2/accounts/reports`, `ReportsHelper`, `HeatmapHelper`, and reused dashboard `reports.js` are aligned for frontend report downloads. `agents`, `inboxes`, `labels`, `teams`, `conversations_summary`, and `conversation_traffic` now return `text/csv` attachments with Chatwoot filenames and headers; standard report CSVs include the Chatwoot reporting-period row and frontend Unix-second `since/until` support; `conversation_traffic` accepts frontend `days_before`/`timezone_offset` without requiring `since/until` while preserving `since/until` compatibility. P3.40 raw JSON/date parity for non-download v2 report, live report, and summary report endpoints remains 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 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. - Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`. @@ -140,6 +140,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.41 API v2 reports CSV/download parity | Implemented for reused dashboard report download clients: `GET /api/v2/accounts/:account_id/reports/agents`, `/inboxes`, `/labels`, `/teams`, `/conversations_summary`, and `/conversation_traffic` now follow the inspected Chatwoot v2 reports controller, CSV templates, `ReportsHelper`, `HeatmapHelper`, and `reports.js` download actions by returning `text/csv` attachments instead of JSON. Standard download reports emit Chatwoot filenames, reporting-period row, localized English header text from the reference locale, readable duration formatting, and account-scoped agent/inbox/team/label/conversation-summary rows. `conversation_traffic` accepts frontend `days_before` plus `timezone_offset` without requiring `since/until`, emits `conversation_traffic_reports.csv`, a timezone row, and hourly heatmap rows; explicit `since/until` remains compatible for local callers. | Keep in Review; reopen from B12 reports smoke or fresh reference evidence for exact metric-builder aggregation drift, ActiveSupport timezone display-name drift, business-hours summary drift, channel display-name drift, or CSV row ordering/body differences beyond the inspected download contract. | Focused Analytics handler CSV tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. | | 0 | P3.40 API v2 reports envelope/date parity | Implemented for reused dashboard reports clients: `GET /api/v2/accounts/:account_id/reports*`, `/summary_reports/*`, and `/live_reports/*` now follow the inspected Chatwoot v2 controllers by accepting Unix-second `since/until` params from `reports.js`/`summaryReports.js`, preserving existing RFC3339 compatibility for local callers, and returning raw JSON arrays/objects for reports, live reports, and summary reports instead of the local `{ success, data }` envelope. | Keep in Review; reopen from B12 reports smoke or fresh reference evidence for CSV body column drift, deeper metric-builder shape drift, authorization payload drift, or report CSV download content differences beyond this raw JSON/date-parameter slice. | Focused Analytics/SummaryReport/LiveReport handler tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. | | 0 | P3.39 profile MFA route/payload parity | Implemented for reused dashboard profile MFA settings: `GET/PATCH/PUT /api/v1/profile`, profile action routes, and no-trailing `GET/POST/DELETE /api/v1/profile/mfa` plus `POST /profile/mfa/verify` and `POST /profile/mfa/backup_codes` are registered and tracked from `routes.rb:421-435`; profile MFA status returns raw `{ feature_available, enabled, backup_codes_generated }`; setup returns raw `{ provisioning_url, secret }` while persisting a pending TOTP secret; verify accepts frontend `otp_code`, enables MFA, and returns raw `{ enabled: true, backup_codes }`; backup-code regeneration validates the active OTP; destroy validates the current password plus OTP/backup-code and returns raw `{ enabled: false }`; legacy `/api/v1/auth/mfa/*` routes remain compatibility endpoints. | Keep in Review; reopen only if live profile security settings smoke exposes MFA feature-flag gating drift, backup-code storage shape drift, Devise password validation drift, or profile serializer fields beyond the inspected controller/Jbuilder/frontend contract. | Focused MFA handler/router/route-parity tests, route dump/parity regeneration (`967` routes; `421 exact`, `18 parameter-compatible`, `0 missing out of 439`), full `go test ./...`, and `git diff --check` must pass. | | 0 | P3.38 Facebook callbacks route/payload parity | Implemented for reused dashboard Facebook login and reauthorization flows: account-level `GET/POST /api/v1/accounts/:account_id/callbacks/register_facebook_page`, `POST /callbacks/facebook_pages`, frontend `.json` alias, and `POST /callbacks/reauthorize_page` are registered and tracked from `routes.rb:106-111`; page discovery exchanges `omniauth_token` for a long-lived user token through a fakeable Facebook provider boundary and returns `{ data: { page_details, user_access_token } }` with Chatwoot `exists` flags; register creates a Facebook inbox/channel from callback payloads and returns the raw callback Jbuilder fields; reauthorize validates account-scoped Facebook inboxes, refreshes the matching page token, clears `reauthorization_required`, updates inbox channel config, and returns `{ data: inbox }`. | Keep in Review; reopen only if live Facebook login smoke exposes Koala pagination/body drift, avatar import side-effect requirements, Facebook page subscription setup drift, or inbox serializer fields beyond the inspected callbacks controller/Jbuilder/frontend contract. | Focused Facebook callback handler tests, router/route-parity tests, route dump/parity regeneration (`963` routes; `407 exact`, `18 parameter-compatible`, `0 missing out of 425`), full `go test ./...`, and `git diff --check` must pass. | @@ -263,6 +264,7 @@ This ledger records the committed parity checkpoints that future slices should b | Commit | Scope | Verification summary | Follow-up state | | --- | --- | --- | --- | +| `feat(reports): align v2 csv downloads` | Advances P3.41 API v2 reports CSV/download parity by matching Chatwoot `Api::V2::Accounts::ReportsController`, CSV templates `agents/inboxes/labels/teams/conversations_summary/conversation_traffic`, `ReportsHelper`, `HeatmapHelper`, locale CSV labels, and reused dashboard `reports.js` download actions. GoChat now returns `text/csv` attachments for report downloads, emits Chatwoot filenames and headers, keeps Unix-second `since/until` report period support, formats durations like Chatwoot's time presenter, and lets `conversation_traffic` run from frontend `days_before`/`timezone_offset` without requiring `since/until`. | `go test ./internal/handler/api/v1 -run Analytics -count=1`; full `go test ./...`; `git diff --check`. Route artifacts are unchanged. | P3.41 moves to Review for current CSV/download evidence; continue Phase 2/3 drift audit for metric-builder/timezone/body-depth drift, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. | | `feat(reports): align v2 report envelopes` | Advances P3.40 API v2 reports envelope/date-parameter parity by matching Chatwoot `Api::V2::Accounts::ReportsController`, `SummaryReportsController`, `LiveReportsController`, routes `481-509`, and reused dashboard `reports.js`, `summaryReports.js`, and `liveReports.js`. GoChat now accepts Unix-second `since/until` report params from the frontend while preserving RFC3339 compatibility, and returns raw Chatwoot-style JSON arrays/objects for reports, summary reports, and live reports instead of the local `{ success, data }` envelope. | `go test ./internal/handler/api/v1 -run 'Analytics\|SummaryReport\|LiveReport' -count=1`; full `go test ./...`; `git diff --check`. Route artifacts are unchanged. | P3.40 moves to Review for the current v2 report JSON/date evidence; continue Phase 2/3 drift audit for CSV/metric-builder depth, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. | | `feat(profile): align mfa payloads` | Advances P3.39 profile MFA route/payload parity by matching Chatwoot `Api::V1::ProfilesController`, `Api::V1::Profile::MfaController`, MFA Jbuilder views, routes `421-435`, and reused dashboard `mfa.js`/profile MFA settings flow. GoChat now exposes no-trailing profile MFA routes, tracks profile route parity, returns raw MFA status/create/verify/destroy/backup-code payloads, persists pending setup secrets, verifies frontend `otp_code`, returns and stores backup-code hashes, and validates password plus OTP/backup-code for disable while preserving legacy `/auth/mfa` compatibility endpoints. | `go test ./internal/auth ./internal/handler/api/v1 ./internal/router ./cmd/route_parity -run 'MFA\|Profile\|RegisterRoutes\|RouteParity' -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`; full `go test ./...`; `git diff --check`. Route dump is `TOTAL: 967`; tracked route parity is `421 exact, 0 method-compatible, 18 parameter-compatible, 0 missing out of 439`. | P3.39 moves to Review for current profile MFA evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. | | `feat(channels): align facebook callbacks` | Advances P3.38 Facebook callbacks route/payload parity by matching Chatwoot `Api::V1::Accounts::CallbacksController`, callback Jbuilder views/specs, routes `106-111`, and reused dashboard `fbChannel.js`/`endPoints.js`/Facebook reauthorize store flow. GoChat now exposes account-level register/page-discovery/reauthorize callback routes, supports the frontend `.json` page-discovery alias, exchanges short-lived login tokens through a fakeable provider boundary, marks existing Facebook pages, creates Facebook inboxes from callback payloads, persists `user_access_token`, refreshes page tokens and inbox channel config on reauthorize, and returns Chatwoot-shaped raw callback payloads. | `go test ./internal/handler/api/v1 ./internal/channel/facebook ./internal/router ./cmd/route_parity -run 'Facebook\|RegisterRoutes\|RouteParity' -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`; full `go test ./...`; `git diff --check`. Route dump is `TOTAL: 963`; tracked route parity is `407 exact, 0 method-compatible, 18 parameter-compatible, 0 missing out of 425`. | P3.38 moves to Review for current Facebook callback evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. | @@ -2512,3 +2514,4 @@ Verification milestone gates: - 2026-06-06: P3.39 profile MFA checkpoint committed as `21421c6 feat(profile): align mfa payloads`; audited Chatwoot profile/profile-MFA controllers, MFA Jbuilder views, routes `421-435`, reused dashboard `mfa.js`, and profile MFA settings flow. GoChat now tracks profile routes, registers no-trailing and trailing MFA route variants, returns raw MFA status/create/verify/destroy/backup-code payloads, persists pending setup secrets, stores backup-code hashes, validates password plus OTP/backup-code for disable, and preserves legacy `/api/v1/auth/mfa/*` compatibility endpoints. Focused MFA/profile/router/route-parity tests, route dump/parity regeneration (`TOTAL: 967`; `421 exact`, `18 parameter-compatible`, `0 missing out of 439`), full `go test ./...`, and `git diff --check` passed. - 2026-06-06: Documentation checkpoint prepared as `docs: land parity execution tracker`; confirms the clean committed baseline at `21421c6 feat(profile): align mfa payloads`, makes `docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md` the self-contained active tracker for the user-confirmed scope, marks `.hermes/plans/*` as source notes only, replaces stale next-slice wording with the Phase 2/3 audit, Phase 6 placeholder audit, and B12 live-smoke queue, and records the missing P3.35-P3.39 progress entries. Verification for this docs-only checkpoint: `git diff --check`. - 2026-06-06: P3.40 API v2 reports checkpoint prepared as `feat(reports): align v2 report envelopes`; audited Chatwoot v2 reports, summary reports, and live reports controllers plus reused dashboard `reports.js`, `summaryReports.js`, and `liveReports.js`. GoChat report handlers now accept frontend Unix-second `since/until` params while preserving RFC3339 compatibility, and v2 reports/live_reports/summary_reports return raw Chatwoot JSON arrays/objects instead of the local `{ success, data }` envelope. Focused Analytics/SummaryReport/LiveReport handler tests, escalated full `go test ./...`, and `git diff --check` passed. No route artifacts change. +- 2026-06-06: P3.41 API v2 reports CSV/download checkpoint prepared as `feat(reports): align v2 csv downloads`; audited Chatwoot v2 reports CSV actions, templates, ReportsHelper/HeatmapHelper, locale headers, and reused dashboard report download clients. GoChat now returns `text/csv` attachments for agents/inboxes/labels/teams/conversations_summary/conversation_traffic, emits Chatwoot filenames/reporting-period/header rows, renders readable durations, and accepts `conversation_traffic` frontend `days_before`/`timezone_offset` without requiring `since/until`. Focused Analytics CSV tests, escalated full `go test ./...`, and `git diff --check` passed. No route artifacts change. diff --git a/internal/handler/api/v1/analytics_handler.go b/internal/handler/api/v1/analytics_handler.go index d7ee78ea..7484977f 100644 --- a/internal/handler/api/v1/analytics_handler.go +++ b/internal/handler/api/v1/analytics_handler.go @@ -1,6 +1,7 @@ package v1 import ( + "encoding/csv" "net/http" "strconv" "time" @@ -100,6 +101,93 @@ func parseChatwootReportTime(value string) (time.Time, error) { return time.Parse(time.RFC3339, value) } +func parseReportBusinessHours(c *gin.Context) bool { + return c.Query("business_hours") == "true" || c.Query("business_hours") == "1" +} + +func parseConversationTrafficRange(c *gin.Context) (time.Time, time.Time, float64, bool) { + timezoneOffset := 0.0 + if raw := c.Query("timezone_offset"); raw != "" { + parsed, err := strconv.ParseFloat(raw, 64) + if err != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid timezone_offset") + return time.Time{}, time.Time{}, 0, false + } + timezoneOffset = parsed + } + + if c.Query("since") != "" || c.Query("until") != "" { + since, until, ok := parseDateRange(c) + return since, until, timezoneOffset, ok + } + + daysBefore := 6 + if raw := c.Query("days_before"); raw != "" { + parsed, err := strconv.Atoi(raw) + if err != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid days_before") + return time.Time{}, time.Time{}, 0, false + } + daysBefore = parsed + } + + loc := fixedOffsetLocation(timezoneOffset) + now := time.Now().In(loc) + untilLocal := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc) + sinceLocal := untilLocal.AddDate(0, 0, -daysBefore) + return sinceLocal.UTC(), untilLocal.UTC(), timezoneOffset, true +} + +func fixedOffsetLocation(offsetHours float64) *time.Location { + seconds := int(offsetHours * 3600) + return time.FixedZone(reportTimezoneName(offsetHours), seconds) +} + +func reportTimezoneName(offsetHours float64) string { + if offsetHours == 0 { + return "UTC" + } + sign := "+" + if offsetHours < 0 { + sign = "-" + offsetHours = -offsetHours + } + totalMinutes := int(offsetHours*60 + 0.5) + return "UTC" + sign + twoDigit(totalMinutes/60) + ":" + twoDigit(totalMinutes%60) +} + +func twoDigit(value int) string { + if value < 10 { + return "0" + strconv.Itoa(value) + } + return strconv.Itoa(value) +} + +func writeReportCSV(c *gin.Context, filename string, since, until time.Time, headers []string, rows [][]string) { + c.Header("Content-Type", "text/csv") + c.Header("Content-Disposition", "attachment; filename="+filename+".csv") + writer := csv.NewWriter(c.Writer) + _ = writer.Write([]string{"Reporting period " + since.Format("2006-01-02") + " to " + until.Format("2006-01-02")}) + _ = writer.Write([]string{}) + _ = writer.Write(headers) + for _, row := range rows { + _ = writer.Write(row) + } + writer.Flush() +} + +func writeConversationTrafficCSV(c *gin.Context, timezoneOffset float64, rows [][]string) { + c.Header("Content-Type", "text/csv") + c.Header("Content-Disposition", "attachment; filename=conversation_traffic_reports.csv") + writer := csv.NewWriter(c.Writer) + _ = writer.Write([]string{"Timezone", reportTimezoneName(timezoneOffset)}) + _ = writer.Write([]string{}) + for _, row := range rows { + _ = writer.Write(row) + } + writer.Flush() +} + // Summary returns account-level aggregated metrics. // GET /api/v1/accounts/:account_id/reports/summary func (h *AnalyticsHandler) Summary(c *gin.Context) { @@ -134,14 +222,21 @@ func (h *AnalyticsHandler) AgentMetrics(c *gin.Context) { return } - result, err := h.svc.GetAgentMetrics(c.Request.Context(), accountID, since, until) + result, err := h.svc.GetAgentReportCSVRows(c.Request.Context(), accountID, since, until, parseReportBusinessHours(c)) if err != nil { applogger.L().Errorf("Agent metrics report: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to generate agent metrics") return } - c.JSON(http.StatusOK, result) + writeReportCSV(c, "agents_report", since, until, []string{ + "Agent name", + "Assigned conversations", + "Avg first response time", + "Avg resolution time", + "Avg customer waiting time", + "Resolution Count", + }, result) } // InboxMetrics returns metrics grouped by inbox. @@ -156,14 +251,20 @@ func (h *AnalyticsHandler) InboxMetrics(c *gin.Context) { return } - result, err := h.svc.GetInboxMetrics(c.Request.Context(), accountID, since, until) + result, err := h.svc.GetInboxReportCSVRows(c.Request.Context(), accountID, since, until, parseReportBusinessHours(c)) if err != nil { applogger.L().Errorf("Inbox metrics report: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to generate inbox metrics") return } - c.JSON(http.StatusOK, result) + writeReportCSV(c, "inboxes_report", since, until, []string{ + "Inbox name", + "Inbox type", + "No. of conversations", + "Avg first response time", + "Avg resolution time", + }, result) } // LabelMetrics returns metrics grouped by label. @@ -178,14 +279,21 @@ func (h *AnalyticsHandler) LabelMetrics(c *gin.Context) { return } - result, err := h.svc.GetLabelMetrics(c.Request.Context(), accountID, since, until) + result, err := h.svc.GetLabelReportCSVRows(c.Request.Context(), accountID, since, until, parseReportBusinessHours(c)) if err != nil { applogger.L().Errorf("Label metrics report: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to generate label metrics") return } - c.JSON(http.StatusOK, result) + writeReportCSV(c, "labels_report", since, until, []string{ + "Label", + "No. of conversations", + "Avg first response time", + "Avg resolution time", + "Avg reply time", + "Resolution Count", + }, result) } // TeamMetrics returns metrics grouped by team. @@ -200,14 +308,21 @@ func (h *AnalyticsHandler) TeamMetrics(c *gin.Context) { return } - result, err := h.svc.GetTeamMetrics(c.Request.Context(), accountID, since, until) + result, err := h.svc.GetTeamReportCSVRows(c.Request.Context(), accountID, since, until, parseReportBusinessHours(c)) if err != nil { applogger.L().Errorf("Team metrics report: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to generate team metrics") return } - c.JSON(http.StatusOK, result) + writeReportCSV(c, "teams_report", since, until, []string{ + "Team name", + "Conversations count", + "Avg first response time", + "Avg resolution time", + "Avg customer waiting time", + "Resolution Count", + }, result) } // ConversationTraffic returns daily conversation traffic time-series. @@ -217,19 +332,19 @@ func (h *AnalyticsHandler) ConversationTraffic(c *gin.Context) { if !ok { return } - since, until, ok := parseDateRange(c) + since, until, timezoneOffset, ok := parseConversationTrafficRange(c) if !ok { return } - result, err := h.svc.GetConversationTraffic(c.Request.Context(), accountID, since, until) + result, err := h.svc.GetConversationTrafficCSVRows(c.Request.Context(), accountID, since, until, timezoneOffset) if err != nil { applogger.L().Errorf("Conversation traffic report: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to generate conversation traffic") return } - c.JSON(http.StatusOK, result) + writeConversationTrafficCSV(c, timezoneOffset, result) } // BotSummary returns bot-level summary metrics. @@ -294,14 +409,22 @@ func (h *AnalyticsHandler) ConversationsSummary(c *gin.Context) { return } - result, err := h.svc.GetConversationsSummary(c.Request.Context(), accountID, since, until) + result, err := h.svc.GetConversationsSummaryCSVRows(c.Request.Context(), accountID, since, until, parseReportBusinessHours(c)) if err != nil { applogger.L().Errorf("Conversations summary report: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to generate conversations summary") return } - c.JSON(http.StatusOK, result) + writeReportCSV(c, "conversations_summary_report", since, until, []string{ + "Conversations", + "Messages received", + "Messages sent", + "Avg first response time", + "Avg resolution time", + "Resolution count", + "Avg customer waiting time", + }, result) } // BotMetrics returns bot metrics. diff --git a/internal/handler/api/v1/analytics_handler_test.go b/internal/handler/api/v1/analytics_handler_test.go index 7700a62e..85edf449 100644 --- a/internal/handler/api/v1/analytics_handler_test.go +++ b/internal/handler/api/v1/analytics_handler_test.go @@ -1,9 +1,11 @@ package v1 import ( + "encoding/csv" "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -37,7 +39,13 @@ func (s *AnalyticsHandlerTestSuite) SetupSuite() { s.db = db s.Require().NoError(db.AutoMigrate( &model.Account{}, + &model.User{}, + &model.Inbox{}, + &model.Team{}, + &model.Tag{}, &model.Conversation{}, + &model.ConversationLabel{}, + &model.Message{}, &model.ReportingEvent{}, &model.ReportingEventsRollup{}, )) @@ -62,6 +70,7 @@ func (s *AnalyticsHandlerTestSuite) SetupSuite() { accounts.GET("/reports/labels", s.handler.LabelMetrics) accounts.GET("/reports/teams", s.handler.TeamMetrics) accounts.GET("/reports/conversation_traffic", s.handler.ConversationTraffic) + accounts.GET("/reports/conversations_summary", s.handler.ConversationsSummary) s.router = r } @@ -73,7 +82,13 @@ func (s *AnalyticsHandlerTestSuite) TearDownSuite() { func (s *AnalyticsHandlerTestSuite) SetupTest() { s.db.Exec("DELETE FROM reporting_events") s.db.Exec("DELETE FROM reporting_events_rollups") + s.db.Exec("DELETE FROM messages") + s.db.Exec("DELETE FROM conversation_labels") s.db.Exec("DELETE FROM conversations") + s.db.Exec("DELETE FROM tags") + s.db.Exec("DELETE FROM teams") + s.db.Exec("DELETE FROM inboxes") + s.db.Exec("DELETE FROM users") } // ========== parseAccountID / parseDateRange edge cases ========== @@ -186,6 +201,31 @@ func (s *AnalyticsHandlerTestSuite) TestAgentMetrics_EmptyData() { s.Equal(http.StatusOK, w.Code) } +func (s *AnalyticsHandlerTestSuite) TestAgentMetrics_ReturnsChatwootCSVDownload() { + user := model.User{AccountID: s.accountID, Name: "Ada Agent", Email: "ada@example.com", Password: "secret"} + s.Require().NoError(s.db.Create(&user).Error) + resolvedAt := parseTime("2025-01-16T10:00:00Z") + conv := model.Conversation{AccountID: s.accountID, InboxID: 1, ContactID: 1, AssigneeID: &user.ID, Status: string(model.ConversationStatusResolved), ChannelType: "web_widget", Channel: "web_widget", ResolvedAt: &resolvedAt, Base: model.Base{CreatedAt: parseTime("2025-01-15T10:00:00Z")}} + s.Require().NoError(s.db.Create(&conv).Error) + s.seedReportingEvent(model.MetricNameFirstResponse, 120, user.ID, nil, conv.ID, "2025-01-15T10:05:00Z") + s.seedReportingEvent(model.MetricNameReplyTime, 45, user.ID, nil, conv.ID, "2025-01-15T10:06:00Z") + s.seedReportingEvent(model.MetricNameResolutionTime, 3600, user.ID, nil, conv.ID, "2025-01-16T10:00:00Z") + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, + "/api/v1/accounts/1/reports/agents?since=1735689600&until=1738368000", nil) + s.router.ServeHTTP(w, req) + s.Equal(http.StatusOK, w.Code) + s.Equal("text/csv", w.Header().Get("Content-Type")) + s.Equal("attachment; filename=agents_report.csv", w.Header().Get("Content-Disposition")) + s.NotContains(w.Body.String(), "success") + + rows := readCSVRows(s.T(), w.Body.String()) + s.Equal([]string{"Reporting period 2025-01-01 to 2025-02-01"}, rows[0]) + s.Equal("Agent name", rows[1][0]) + s.Equal([]string{"Ada Agent", "1", "2 minutes", "1 hour", "45 seconds", "1"}, rows[2]) +} + // ========== InboxMetrics ========== func (s *AnalyticsHandlerTestSuite) TestInboxMetrics_InvalidAccountID() { @@ -204,6 +244,21 @@ func (s *AnalyticsHandlerTestSuite) TestInboxMetrics_EmptyData() { s.Equal(http.StatusOK, w.Code) } +func (s *AnalyticsHandlerTestSuite) TestInboxMetrics_ReturnsCSVHeaders() { + inbox := model.Inbox{AccountID: s.accountID, Name: "Support", ChannelType: "Channel::WebWidget", ChannelID: 1} + s.Require().NoError(s.db.Create(&inbox).Error) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, + "/api/v1/accounts/1/reports/inboxes?since=1735689600&until=1738368000", nil) + s.router.ServeHTTP(w, req) + s.Equal(http.StatusOK, w.Code) + s.Equal("attachment; filename=inboxes_report.csv", w.Header().Get("Content-Disposition")) + rows := readCSVRows(s.T(), w.Body.String()) + s.Equal([]string{"Inbox name", "Inbox type", "No. of conversations", "Avg first response time", "Avg resolution time"}, rows[1]) + s.Equal([]string{"Support", "Channel::WebWidget", "0", "N/A", "N/A"}, rows[2]) +} + // ========== LabelMetrics ========== func (s *AnalyticsHandlerTestSuite) TestLabelMetrics_InvalidAccountID() { @@ -222,6 +277,21 @@ func (s *AnalyticsHandlerTestSuite) TestLabelMetrics_EmptyData() { s.Equal(http.StatusOK, w.Code) } +func (s *AnalyticsHandlerTestSuite) TestLabelMetrics_ReturnsCSVHeaders() { + label := model.Tag{AccountID: s.accountID, Name: "billing"} + s.Require().NoError(s.db.Create(&label).Error) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, + "/api/v1/accounts/1/reports/labels?since=1735689600&until=1738368000", nil) + s.router.ServeHTTP(w, req) + s.Equal(http.StatusOK, w.Code) + s.Equal("attachment; filename=labels_report.csv", w.Header().Get("Content-Disposition")) + rows := readCSVRows(s.T(), w.Body.String()) + s.Equal([]string{"Label", "No. of conversations", "Avg first response time", "Avg resolution time", "Avg reply time", "Resolution Count"}, rows[1]) + s.Equal([]string{"billing", "0", "N/A", "N/A", "N/A", "0"}, rows[2]) +} + // ========== TeamMetrics ========== func (s *AnalyticsHandlerTestSuite) TestTeamMetrics_InvalidAccountID() { @@ -240,6 +310,21 @@ func (s *AnalyticsHandlerTestSuite) TestTeamMetrics_EmptyData() { s.Equal(http.StatusOK, w.Code) } +func (s *AnalyticsHandlerTestSuite) TestTeamMetrics_ReturnsCSVHeaders() { + team := model.Team{AccountID: s.accountID, Name: "Escalations"} + s.Require().NoError(s.db.Create(&team).Error) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, + "/api/v1/accounts/1/reports/teams?since=1735689600&until=1738368000", nil) + s.router.ServeHTTP(w, req) + s.Equal(http.StatusOK, w.Code) + s.Equal("attachment; filename=teams_report.csv", w.Header().Get("Content-Disposition")) + rows := readCSVRows(s.T(), w.Body.String()) + s.Equal([]string{"Team name", "Conversations count", "Avg first response time", "Avg resolution time", "Avg customer waiting time", "Resolution Count"}, rows[1]) + s.Equal([]string{"Escalations", "0", "N/A", "N/A", "N/A", "0"}, rows[2]) +} + // ========== ConversationTraffic ========== func (s *AnalyticsHandlerTestSuite) TestConversationTraffic_InvalidAccountID() { @@ -253,11 +338,51 @@ func (s *AnalyticsHandlerTestSuite) TestConversationTraffic_InvalidAccountID() { func (s *AnalyticsHandlerTestSuite) TestConversationTraffic_EmptyData() { w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, - "/api/v1/accounts/1/reports/conversation_traffic?since=2025-01-01T00:00:00Z&until=2025-01-31T00:00:00Z", nil) + "/api/v1/accounts/1/reports/conversation_traffic?days_before=1&timezone_offset=0", nil) s.router.ServeHTTP(w, req) s.Equal(http.StatusOK, w.Code) } +func (s *AnalyticsHandlerTestSuite) TestConversationTraffic_ReturnsCSVWithoutSinceUntil() { + now := time.Now().UTC() + yesterdayNoon := time.Date(now.Year(), now.Month(), now.Day(), 12, 0, 0, 0, time.UTC).AddDate(0, 0, -1) + conv := model.Conversation{AccountID: s.accountID, InboxID: 1, ContactID: 1, Status: string(model.ConversationStatusOpen), ChannelType: "web_widget", Channel: "web_widget", Base: model.Base{CreatedAt: yesterdayNoon}} + s.Require().NoError(s.db.Create(&conv).Error) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, + "/api/v1/accounts/1/reports/conversation_traffic?days_before=1&timezone_offset=0", nil) + s.router.ServeHTTP(w, req) + s.Equal(http.StatusOK, w.Code) + s.Equal("attachment; filename=conversation_traffic_reports.csv", w.Header().Get("Content-Disposition")) + rows := readCSVRows(s.T(), w.Body.String()) + s.Equal([]string{"Timezone", "UTC"}, rows[0]) + s.Equal("Start of the hour", rows[1][0]) + s.Equal("12:00", rows[14][0]) + s.Equal("1", rows[14][1]) +} + +func (s *AnalyticsHandlerTestSuite) TestConversationsSummary_ReturnsCSVDownload() { + conv := model.Conversation{AccountID: s.accountID, InboxID: 1, ContactID: 1, Status: string(model.ConversationStatusOpen), ChannelType: "web_widget", Channel: "web_widget", Base: model.Base{CreatedAt: parseTime("2025-01-15T10:00:00Z")}} + s.Require().NoError(s.db.Create(&conv).Error) + incoming := model.Message{AccountID: s.accountID, InboxID: 1, ConversationID: conv.ID, MessageType: string(model.MessageTypeIncoming), Content: "hi", Base: model.Base{CreatedAt: parseTime("2025-01-15T10:01:00Z")}} + outgoing := model.Message{AccountID: s.accountID, InboxID: 1, ConversationID: conv.ID, MessageType: string(model.MessageTypeOutgoing), Content: "hello", Base: model.Base{CreatedAt: parseTime("2025-01-15T10:02:00Z")}} + s.Require().NoError(s.db.Create(&incoming).Error) + s.Require().NoError(s.db.Create(&outgoing).Error) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, + "/api/v1/accounts/1/reports/conversations_summary?since=1735689600&until=1738368000", nil) + s.router.ServeHTTP(w, req) + s.Equal(http.StatusOK, w.Code) + s.Equal("attachment; filename=conversations_summary_report.csv", w.Header().Get("Content-Disposition")) + rows := readCSVRows(s.T(), w.Body.String()) + s.Equal([]string{"Conversations", "Messages received", "Messages sent", "Avg first response time", "Avg resolution time", "Resolution count", "Avg customer waiting time"}, rows[1]) + s.Equal("1", rows[2][0]) + s.Equal("1", rows[2][1]) + s.Equal("1", rows[2][2]) +} + // ========== Nil service guard ========== func (s *AnalyticsHandlerTestSuite) TestNilService() { @@ -271,3 +396,30 @@ func parseTime(s string) time.Time { t, _ := time.Parse(time.RFC3339, s) return t } + +func (s *AnalyticsHandlerTestSuite) seedReportingEvent(name string, value float64, userID uint, inboxID *uint, conversationID uint, createdAt string) { + event := model.ReportingEvent{ + AccountID: s.accountID, + Name: name, + Value: value, + ValueInBusinessHours: value, + UserID: &userID, + InboxID: inboxID, + ConversationID: &conversationID, + EventStartTime: parseTime(createdAt), + EventEndTime: parseTime(createdAt), + Base: model.Base{CreatedAt: parseTime(createdAt)}, + } + s.Require().NoError(s.db.Create(&event).Error) +} + +func readCSVRows(t *testing.T, body string) [][]string { + t.Helper() + reader := csv.NewReader(strings.NewReader(body)) + reader.FieldsPerRecord = -1 + rows, err := reader.ReadAll() + if err != nil { + t.Fatalf("failed to read csv: %v\n%s", err, body) + } + return rows +} diff --git a/internal/service/analytics_csv.go b/internal/service/analytics_csv.go new file mode 100644 index 00000000..42a25977 --- /dev/null +++ b/internal/service/analytics_csv.go @@ -0,0 +1,422 @@ +package service + +import ( + "context" + "fmt" + "strconv" + "time" + + "github.com/gochat/gochat/internal/model" + "gorm.io/gorm" +) + +type reportCSVMetricSet struct { + ConversationsCount int64 + ResolvedCount int64 + AvgResolution float64 + AvgFirstResponse float64 + AvgReply float64 +} + +func (s *AnalyticsService) GetAgentReportCSVRows(ctx context.Context, accountID uint, since, until time.Time, businessHours bool) ([][]string, error) { + db, err := s.analyticsDB() + if err != nil { + return nil, err + } + metrics, err := s.reportMetricsByDimension(ctx, accountID, since, until, "agent", businessHours) + if err != nil { + return nil, err + } + var users []model.User + if err := db.WithContext(ctx).Where("account_id = ?", accountID).Order("id ASC").Find(&users).Error; err != nil { + return nil, err + } + rows := make([][]string, 0, len(users)) + for _, user := range users { + metric := metrics[user.ID] + rows = append(rows, append([]string{user.Name}, readableReportMetrics(metric)...)) + } + return rows, nil +} + +func (s *AnalyticsService) GetInboxReportCSVRows(ctx context.Context, accountID uint, since, until time.Time, businessHours bool) ([][]string, error) { + db, err := s.analyticsDB() + if err != nil { + return nil, err + } + metrics, err := s.reportMetricsByDimension(ctx, accountID, since, until, "inbox", businessHours) + if err != nil { + return nil, err + } + var inboxes []model.Inbox + if err := db.WithContext(ctx).Where("account_id = ?", accountID).Order("id ASC").Find(&inboxes).Error; err != nil { + return nil, err + } + rows := make([][]string, 0, len(inboxes)) + for _, inbox := range inboxes { + metric := metrics[inbox.ID] + rows = append(rows, []string{ + inbox.Name, + inbox.ChannelType, + strconv.FormatInt(metric.ConversationsCount, 10), + formatReportDuration(metric.AvgFirstResponse), + formatReportDuration(metric.AvgResolution), + }) + } + return rows, nil +} + +func (s *AnalyticsService) GetTeamReportCSVRows(ctx context.Context, accountID uint, since, until time.Time, businessHours bool) ([][]string, error) { + db, err := s.analyticsDB() + if err != nil { + return nil, err + } + metrics, err := s.reportMetricsByDimension(ctx, accountID, since, until, "team", businessHours) + if err != nil { + return nil, err + } + var teams []model.Team + if err := db.WithContext(ctx).Where("account_id = ?", accountID).Order("id ASC").Find(&teams).Error; err != nil { + return nil, err + } + rows := make([][]string, 0, len(teams)) + for _, team := range teams { + metric := metrics[team.ID] + rows = append(rows, append([]string{team.Name}, readableReportMetrics(metric)...)) + } + return rows, nil +} + +func (s *AnalyticsService) GetLabelReportCSVRows(ctx context.Context, accountID uint, since, until time.Time, businessHours bool) ([][]string, error) { + db, err := s.analyticsDB() + if err != nil { + return nil, err + } + metrics, err := s.reportMetricsByLabel(ctx, accountID, since, until, businessHours) + if err != nil { + return nil, err + } + var labels []model.Tag + if err := db.WithContext(ctx).Where("account_id = ?", accountID).Order("id ASC").Find(&labels).Error; err != nil { + return nil, err + } + rows := make([][]string, 0, len(labels)) + for _, label := range labels { + metric := metrics[label.ID] + rows = append(rows, []string{ + label.Name, + strconv.FormatInt(metric.ConversationsCount, 10), + formatReportDuration(metric.AvgFirstResponse), + formatReportDuration(metric.AvgResolution), + formatReportDuration(metric.AvgReply), + strconv.FormatInt(metric.ResolvedCount, 10), + }) + } + return rows, nil +} + +func (s *AnalyticsService) GetConversationsSummaryCSVRows(ctx context.Context, accountID uint, since, until time.Time, businessHours bool) ([][]string, error) { + summary, err := s.conversationSummary(ctx, accountID, since, until) + if err != nil { + return nil, err + } + return [][]string{{ + intString(summary["conversations_count"]), + intString(summary["incoming_messages_count"]), + intString(summary["outgoing_messages_count"]), + formatReportDuration(floatValue(summary["avg_first_response_time"])), + formatReportDuration(floatValue(summary["avg_resolution_time"])), + intString(summary["resolutions_count"]), + formatReportDuration(floatValue(summary["reply_time"])), + }}, nil +} + +func (s *AnalyticsService) GetConversationTrafficCSVRows(ctx context.Context, accountID uint, since, until time.Time, timezoneOffset float64) ([][]string, error) { + db, err := s.analyticsDB() + if err != nil { + return nil, err + } + loc := time.FixedZone("report", int(timezoneOffset*3600)) + startLocal := time.Date(since.In(loc).Year(), since.In(loc).Month(), since.In(loc).Day(), 0, 0, 0, 0, loc) + endLocal := time.Date(until.In(loc).Year(), until.In(loc).Month(), until.In(loc).Day(), 0, 0, 0, 0, loc) + + dates := make([]string, 0) + for day := startLocal; day.Before(endLocal); day = day.AddDate(0, 0, 1) { + dates = append(dates, day.Format("2006-01-02")) + } + + counts := map[string]map[int]int64{} + var conversations []model.Conversation + if err := db.WithContext(ctx).Where("account_id = ? AND created_at >= ? AND created_at < ?", accountID, since, until).Find(&conversations).Error; err != nil { + return nil, err + } + for _, conversation := range conversations { + localTime := conversation.CreatedAt.In(loc) + dateKey := localTime.Format("2006-01-02") + if counts[dateKey] == nil { + counts[dateKey] = map[int]int64{} + } + counts[dateKey][localTime.Hour()]++ + } + + rows := make([][]string, 0, 25) + rows = append(rows, append([]string{"Start of the hour"}, dates...)) + for hour := 0; hour < 24; hour++ { + row := []string{fmt.Sprintf("%02d:00", hour)} + for _, date := range dates { + row = append(row, strconv.FormatInt(counts[date][hour], 10)) + } + rows = append(rows, row) + } + return rows, nil +} + +func (s *AnalyticsService) reportMetricsByDimension(ctx context.Context, accountID uint, since, until time.Time, dimension string, businessHours bool) (map[uint]reportCSVMetricSet, error) { + db, err := s.analyticsDB() + if err != nil { + return nil, err + } + result := map[uint]reportCSVMetricSet{} + field := map[string]string{"agent": "assignee_id", "inbox": "inbox_id", "team": "team_id"}[dimension] + if field == "" { + return result, nil + } + + if err := loadConversationCounts(ctx, db, result, accountID, since, until, field, false); err != nil { + return nil, err + } + if err := loadConversationCounts(ctx, db, result, accountID, since, until, field, true); err != nil { + return nil, err + } + if err := s.loadDimensionEventAverages(ctx, db, result, accountID, since, until, dimension, businessHours); err != nil { + return nil, err + } + return result, nil +} + +func loadConversationCounts(ctx context.Context, db *gorm.DB, result map[uint]reportCSVMetricSet, accountID uint, since, until time.Time, field string, resolved bool) error { + var rows []struct { + ID uint + Count int64 + } + query := db.WithContext(ctx).Model(&model.Conversation{}). + Select(field+" AS id, COUNT(*) AS count"). + Where("account_id = ? AND "+field+" IS NOT NULL", accountID). + Group(field) + if resolved { + query = query.Where("resolved_at IS NOT NULL AND resolved_at >= ? AND resolved_at < ?", since, until) + } else { + query = query.Where("created_at >= ? AND created_at < ?", since, until) + } + if err := query.Scan(&rows).Error; err != nil { + return err + } + for _, row := range rows { + metric := result[row.ID] + if resolved { + metric.ResolvedCount = row.Count + } else { + metric.ConversationsCount = row.Count + } + result[row.ID] = metric + } + return nil +} + +func (s *AnalyticsService) loadDimensionEventAverages(ctx context.Context, db *gorm.DB, result map[uint]reportCSVMetricSet, accountID uint, since, until time.Time, dimension string, businessHours bool) error { + valueColumn := "reporting_events.value" + if businessHours { + valueColumn = "reporting_events.value_in_business_hours" + } + field := map[string]string{"agent": "reporting_events.user_id", "inbox": "reporting_events.inbox_id", "team": "conversations.team_id"}[dimension] + queryBase := func(names []string) *gorm.DB { + q := db.WithContext(ctx).Model(&model.ReportingEvent{}). + Select(field+" AS id, AVG("+valueColumn+") AS value"). + Where("reporting_events.account_id = ? AND reporting_events.name IN ? AND reporting_events.created_at >= ? AND reporting_events.created_at < ?", accountID, names, since, until). + Where(field + " IS NOT NULL"). + Group(field) + if dimension == "team" { + q = q.Joins("INNER JOIN conversations ON conversations.id = reporting_events.conversation_id") + } + return q + } + load := func(names []string, apply func(*reportCSVMetricSet, float64)) error { + var rows []struct { + ID uint + Value float64 + } + if err := queryBase(names).Scan(&rows).Error; err != nil { + return err + } + for _, row := range rows { + metric := result[row.ID] + apply(&metric, row.Value) + result[row.ID] = metric + } + return nil + } + if err := load([]string{model.MetricNameFirstResponse}, func(metric *reportCSVMetricSet, value float64) { metric.AvgFirstResponse = value }); err != nil { + return err + } + if err := load([]string{"conversation_resolved", model.MetricNameResolutionTime}, func(metric *reportCSVMetricSet, value float64) { metric.AvgResolution = value }); err != nil { + return err + } + return load([]string{model.MetricNameReplyTime}, func(metric *reportCSVMetricSet, value float64) { metric.AvgReply = value }) +} + +func (s *AnalyticsService) reportMetricsByLabel(ctx context.Context, accountID uint, since, until time.Time, businessHours bool) (map[uint]reportCSVMetricSet, error) { + db, err := s.analyticsDB() + if err != nil { + return nil, err + } + result := map[uint]reportCSVMetricSet{} + if err := loadLabelConversationCounts(ctx, db, result, accountID, since, until, false); err != nil { + return nil, err + } + if err := loadLabelConversationCounts(ctx, db, result, accountID, since, until, true); err != nil { + return nil, err + } + if err := loadLabelEventAverages(ctx, db, result, accountID, since, until, businessHours); err != nil { + return nil, err + } + return result, nil +} + +func loadLabelConversationCounts(ctx context.Context, db *gorm.DB, result map[uint]reportCSVMetricSet, accountID uint, since, until time.Time, resolved bool) error { + var rows []struct { + ID uint + Count int64 + } + query := db.WithContext(ctx).Table("conversation_labels"). + Select("conversation_labels.tag_id AS id, COUNT(*) AS count"). + Joins("INNER JOIN conversations ON conversations.id = conversation_labels.conversation_id"). + Where("conversation_labels.account_id = ?", accountID). + Group("conversation_labels.tag_id") + if resolved { + query = query.Where("conversations.resolved_at IS NOT NULL AND conversations.resolved_at >= ? AND conversations.resolved_at < ?", since, until) + } else { + query = query.Where("conversations.created_at >= ? AND conversations.created_at < ?", since, until) + } + if err := query.Scan(&rows).Error; err != nil { + return err + } + for _, row := range rows { + metric := result[row.ID] + if resolved { + metric.ResolvedCount = row.Count + } else { + metric.ConversationsCount = row.Count + } + result[row.ID] = metric + } + return nil +} + +func loadLabelEventAverages(ctx context.Context, db *gorm.DB, result map[uint]reportCSVMetricSet, accountID uint, since, until time.Time, businessHours bool) error { + valueColumn := "reporting_events.value" + if businessHours { + valueColumn = "reporting_events.value_in_business_hours" + } + load := func(names []string, apply func(*reportCSVMetricSet, float64)) error { + var rows []struct { + ID uint + Value float64 + } + err := db.WithContext(ctx).Model(&model.ReportingEvent{}). + Select("conversation_labels.tag_id AS id, AVG("+valueColumn+") AS value"). + Joins("INNER JOIN conversation_labels ON conversation_labels.conversation_id = reporting_events.conversation_id"). + Where("reporting_events.account_id = ? AND reporting_events.name IN ? AND reporting_events.created_at >= ? AND reporting_events.created_at < ?", accountID, names, since, until). + Group("conversation_labels.tag_id").Scan(&rows).Error + if err != nil { + return err + } + for _, row := range rows { + metric := result[row.ID] + apply(&metric, row.Value) + result[row.ID] = metric + } + return nil + } + if err := load([]string{model.MetricNameFirstResponse}, func(metric *reportCSVMetricSet, value float64) { metric.AvgFirstResponse = value }); err != nil { + return err + } + if err := load([]string{"conversation_resolved", model.MetricNameResolutionTime}, func(metric *reportCSVMetricSet, value float64) { metric.AvgResolution = value }); err != nil { + return err + } + return load([]string{model.MetricNameReplyTime}, func(metric *reportCSVMetricSet, value float64) { metric.AvgReply = value }) +} + +func readableReportMetrics(metric reportCSVMetricSet) []string { + return []string{ + strconv.FormatInt(metric.ConversationsCount, 10), + formatReportDuration(metric.AvgFirstResponse), + formatReportDuration(metric.AvgResolution), + formatReportDuration(metric.AvgReply), + strconv.FormatInt(metric.ResolvedCount, 10), + } +} + +func formatReportDuration(seconds float64) string { + value := int64(seconds) + if value <= 0 { + return "N/A" + } + units := []struct { + Name string + Seconds int64 + }{{"day", 86400}, {"hour", 3600}, {"minute", 60}, {"second", 1}} + parts := make([]string, 0, 2) + remaining := value + for _, unit := range units { + count := remaining / unit.Seconds + remaining %= unit.Seconds + if count == 0 { + continue + } + label := unit.Name + if count != 1 { + label += "s" + } + parts = append(parts, strconv.FormatInt(count, 10)+" "+label) + if len(parts) == 2 { + break + } + } + if len(parts) == 0 { + return "0 seconds" + } + return parts[0] + optionalSecondPart(parts) +} + +func optionalSecondPart(parts []string) string { + if len(parts) < 2 { + return "" + } + return " " + parts[1] +} + +func intString(value interface{}) string { + switch typed := value.(type) { + case int: + return strconv.Itoa(typed) + case int64: + return strconv.FormatInt(typed, 10) + case float64: + return strconv.FormatInt(int64(typed), 10) + default: + return fmt.Sprintf("%v", value) + } +} + +func floatValue(value interface{}) float64 { + switch typed := value.(type) { + case float64: + return typed + case int64: + return float64(typed) + case int: + return float64(typed) + default: + return 0 + } +}