From a48b9cda8e996bd16e73631f3632c15ddc9503ac Mon Sep 17 00:00:00 2001 From: Rogee Date: Fri, 5 Jun 2026 20:53:30 +0800 Subject: [PATCH] feat(reports): derive analytics aggregates --- docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md | 43 +- internal/handler/api/v1/analytics_handler.go | 15 +- .../handler/api/v1/live_report_handler.go | 19 +- internal/repository/reporting_event_repo.go | 9 +- .../reporting_events_rollup_repo.go | 9 +- internal/service/analytics_p513_test.go | 149 ++++++ internal/service/analytics_query_helpers.go | 452 ++++++++++++++++++ internal/service/analytics_service.go | 102 ++-- 8 files changed, 726 insertions(+), 72 deletions(-) create mode 100644 internal/service/analytics_p513_test.go create mode 100644 internal/service/analytics_query_helpers.go diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 410a2a37..666389bc 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -16,10 +16,10 @@ Build GoChat as a Go backend that can directly reuse the frontend from `referenc ## Current Baseline -- Current tracking checkpoint: 2026-06-05 after `dd6d019 feat(captain): queue response embedding jobs`, with this implementation checkpoint prepared as `feat(captain): queue copilot response jobs`. -- Latest implementation checkpoint: this checkpoint, prepared as `feat(captain): queue copilot response jobs`. +- Current tracking checkpoint: 2026-06-05 after `37bdac4 feat(captain): queue copilot response jobs`, with this implementation checkpoint prepared as `feat(reports): derive analytics aggregates`. +- Latest implementation checkpoint: this checkpoint, prepared as `feat(reports): derive analytics aggregates`. - Latest documentation-only checkpoint: `2923aae docs: land parity execution tracker`; this document is now the active follow-up plan and supersedes `.hermes/plans/*`. -- Worktree status at this implementation checkpoint: B11.1a aligns Captain assistant CRUD/tools/inbox bindings; B11.1b aligns Captain scenarios and custom tools; B11.1c aligns Captain documents, assistant responses, bulk actions, and custom-tool test payloads; B11.2 aligns Copilot thread/message create/list/get/delete payloads, account/user scoping, and no-LLM fallback persistence; B11.3a aligns Captain preferences show/update payloads and account-level model/feature storage; B11.3b aligns Captain playground request/response payloads, account scoping, v2 history handling, and no-LLM fallback; B11.3c adds the fakeable Captain document sync backend gate with disabled, failed, and fake-success states; B11.3d aligns Captain task request/response payloads, no-provider disabled states, follow-up context, suggestion persistence, and Copilot message tool-call key validation; B11.3e aligns Captain stream DTOs/disabled SSE fallbacks and Copilot push-event payload shapes; B12.1 adds the reusable GoChat server/seed entrypoint plus a Meilisearch-first reused Chatwoot frontend smoke harness and report; B12.2a adds API smoke assertions for auth/profile, inbox, conversation/messages, contact/company, widget config/message, and public CSAT; B12.2b adds a zero-dependency Chrome DevTools browser smoke that loads the reused Chatwoot login and dashboard entrypoints through Vite and checks browser auth/dashboard API requests; B12.3a adds enterprise API smoke assertions for SLA reports/download, CSAT reports/download, automation/macros, audit/custom roles, capacity, Captain, and Copilot; B12.3b adds reused-frontend enterprise browser route navigation for SLA, CSAT, automation, macros, audit logs, custom roles, capacity, Captain, and Copilot request coverage; P5.1 adds the PostgreSQL-backed durable `background_jobs` model/migration plus WorkerPool enqueue, schedule, retry/backoff, dead-letter, idempotency, stale-lock recovery, and focused tests; P5.2 wires `channel.Dispatcher` and `dispatch.EventDispatcher` async paths into durable event jobs with worker replay tests; P5.3 queues Meilisearch write-side index/delete jobs for conversations, messages, contacts, companies, and articles while keeping search reads Meilisearch-first; P5.4 queues automation webhook and email transcript side effects as durable jobs while preserving fakeable delivery boundaries; P5.5 queues Chatwoot-style macro execute fan-out through durable `automation:macro_execution` jobs; P5.6 queues resolve-triggered CSAT survey sends and WhatsApp/Twilio CSAT template creation through durable jobs; P5.7 queues Chatwoot enterprise SLA account scans and applied-SLA evaluation jobs through the durable worker; P5.8 queues Chatwoot-style contact export artifact generation through durable `contact:export` jobs; P5.9 queues normalized provider inbound message persistence/dispatch through durable `webhook:incoming_message_persist` jobs; P5.10 queues Chatwoot `SendReplyJob`-style outbound message delivery through durable `message:send_reply` jobs and provider delivery-status/read-receipt updates through durable webhook status jobs; P5.11 queues Captain document sync, crawl/parser, schedule-sync, response-builder, embedding-update, Copilot response, and Captain conversation response-builder work through durable jobs; P5.12 queues scheduled item fan-out, one-off campaigns, snoozed conversation reopening, account auto-resolution, widget/public message status updates, and account conversation bulk actions through durable jobs. Next active implementation slice is P5.13 analytics aggregation. +- Worktree status at this implementation checkpoint: B11.1a aligns Captain assistant CRUD/tools/inbox bindings; B11.1b aligns Captain scenarios and custom tools; B11.1c aligns Captain documents, assistant responses, bulk actions, and custom-tool test payloads; B11.2 aligns Copilot thread/message create/list/get/delete payloads, account/user scoping, and no-LLM fallback persistence; B11.3a aligns Captain preferences show/update payloads and account-level model/feature storage; B11.3b aligns Captain playground request/response payloads, account scoping, v2 history handling, and no-LLM fallback; B11.3c adds the fakeable Captain document sync backend gate with disabled, failed, and fake-success states; B11.3d aligns Captain task request/response payloads, no-provider disabled states, follow-up context, suggestion persistence, and Copilot message tool-call key validation; B11.3e aligns Captain stream DTOs/disabled SSE fallbacks and Copilot push-event payload shapes; B12.1 adds the reusable GoChat server/seed entrypoint plus a Meilisearch-first reused Chatwoot frontend smoke harness and report; B12.2a adds API smoke assertions for auth/profile, inbox, conversation/messages, contact/company, widget config/message, and public CSAT; B12.2b adds a zero-dependency Chrome DevTools browser smoke that loads the reused Chatwoot login and dashboard entrypoints through Vite and checks browser auth/dashboard API requests; B12.3a adds enterprise API smoke assertions for SLA reports/download, CSAT reports/download, automation/macros, audit/custom roles, capacity, Captain, and Copilot; B12.3b adds reused-frontend enterprise browser route navigation for SLA, CSAT, automation, macros, audit logs, custom roles, capacity, Captain, and Copilot request coverage; P5.1 adds the PostgreSQL-backed durable `background_jobs` model/migration plus WorkerPool enqueue, schedule, retry/backoff, dead-letter, idempotency, stale-lock recovery, and focused tests; P5.2 wires `channel.Dispatcher` and `dispatch.EventDispatcher` async paths into durable event jobs with worker replay tests; P5.3 queues Meilisearch write-side index/delete jobs for conversations, messages, contacts, companies, and articles while keeping search reads Meilisearch-first; P5.4 queues automation webhook and email transcript side effects as durable jobs while preserving fakeable delivery boundaries; P5.5 queues Chatwoot-style macro execute fan-out through durable `automation:macro_execution` jobs; P5.6 queues resolve-triggered CSAT survey sends and WhatsApp/Twilio CSAT template creation through durable jobs; P5.7 queues Chatwoot enterprise SLA account scans and applied-SLA evaluation jobs through the durable worker; P5.8 queues Chatwoot-style contact export artifact generation through durable `contact:export` jobs; P5.9 queues normalized provider inbound message persistence/dispatch through durable `webhook:incoming_message_persist` jobs; P5.10 queues Chatwoot `SendReplyJob`-style outbound message delivery through durable `message:send_reply` jobs and provider delivery-status/read-receipt updates through durable webhook status jobs; P5.11 queues Captain document sync, crawl/parser, schedule-sync, response-builder, embedding-update, Copilot response, and Captain conversation response-builder work through durable jobs; P5.12 queues scheduled item fan-out, one-off campaigns, snoozed conversation reopening, account auto-resolution, widget/public message status updates, and account conversation bulk actions through durable jobs; P5.13a replaces the live report, bot report, conversation summary, inbox-label matrix, first-response distribution, and outgoing-message placeholder responses with persisted conversation/message/reporting-event aggregations. Next active implementation slice is P5.13b scheduled/cached report rollups and timeseries index parity. - `go test ./...` passes. - Route dump succeeds with `TOTAL: 832` after adding the Chatwoot-compatible Twilio delivery-status route plus the legacy namespaced alias. - Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`. @@ -45,7 +45,7 @@ Next ordered checkpoints: | Order | Slice | Required outcome | Primary verification | | --- | --- | --- | --- | -| 1 | P5.13 analytics aggregation | Replace frontend-visible placeholder report values with scheduled or cached real aggregations. | Report service/handler fixtures and freshness/idempotency tests. | +| 1 | P5.13b scheduled/cached analytics | Wire scheduled or lazy cached report rollups and the `/reports` timeseries index path after the P5.13a placeholder burn-down. | Report worker/service fixtures for freshness, idempotency, and timeseries values. | | 2 | B9.3 delayed automation actions | Confirm current-reference delayed automation params and queue any still-synchronous scheduled action execution. | Automation worker fixtures for schedule time, retry, idempotency, and observable failure. | | 3 | Phase 2/3 audit pass | Route/controller/serializer drift found by B12 or new reference inspection is captured as named slices, not free-form TODOs. | Regenerated route parity artifacts and fixture-backed serializer tests. | | 4 | Phase 6 placeholder burn-down | Remaining account/contact/conversation/message/inbox placeholder handlers are either real Chatwoot-compatible flows or explicitly tracked as unsupported reference gaps. | `rg` placeholder audit, route smoke, and endpoint-family tests. | @@ -54,7 +54,7 @@ Next ordered checkpoints: This checkpoint is intended to make the development plan complete enough to track without reading Hermes notes first. -- The next active implementation slice is P5.13 analytics aggregation. P5.11 Captain/Copilot response jobs are now in Review, and B12 has repeatable API/browser/enterprise smoke harnesses in Review; optional live failures should be converted into named slices instead of blocking Phase 5 job work. +- The next active implementation slice is P5.13b scheduled/cached analytics. P5.13a replaced the most visible report placeholders with persisted aggregations; remaining analytics work is report rollup freshness/idempotency and `/reports` timeseries index parity. B12 has repeatable API/browser/enterprise smoke harnesses in Review; optional live failures should be converted into named slices instead of blocking Phase 5 job work. - The Hermes search plan is fully represented by Phase 1/B6. Future search changes must be Meilisearch-first and must not reintroduce production DB fallback. - The Hermes automation/macro/CSAT plan is fully represented by B8/B9 and Phase 5. Durable delayed execution remains visible Phase 5/B9.3 work if the current reference exposes explicit delayed action params; channel-specific template delivery is in Review. - Enterprise scope is fixed: SLA, Audit, CustomRole, AgentCapacity, Captain/Copilot, CSAT, InboxLimit, automation, macros, assignment policies, and related limits/workflows are in scope; SSO/SAML/LDAP/OIDC are out of scope. @@ -64,7 +64,7 @@ Open work after the current checkpoint: | Area | Next concrete action | Tracking location | Done boundary | | --- | --- | --- | --- | -| Phase 5 jobs | Finish analytics aggregation and the B9.3 delayed automation action check on top of the committed durable worker. | `Phase 5: Background Jobs And Integrations` | Report and automation tests prove real aggregation/scheduling, freshness/idempotency, and no frontend-visible placeholder values. | +| Phase 5 jobs | Finish P5.13b scheduled/cached analytics and the B9.3 delayed automation action check on top of the committed durable worker. | `Phase 5: Background Jobs And Integrations` | Report and automation tests prove real aggregation/scheduling, freshness/idempotency, and no frontend-visible placeholder values. | | B12 smoke | Run optional live API/browser/enterprise smoke in a full PostgreSQL/Redis/Meilisearch/Vite environment and convert failures into named slices. | `B12 reused frontend verification breakdown` | `docs/parity/frontend_smoke_report.md` records checked pass/fail results and maps failures to slices. | | Phase 2/3 drift | Expand tracked route/serializer fixtures when B12 exposes frontend-critical gaps. | `Phase 2`, `Phase 3`, `docs/parity/` | Route parity remains 0 missing for tracked frontend routes; serializers have reference fixtures. | | Phase 6 placeholders | Re-run placeholder audit and burn down any frontend-reachable stub. | `Phase 6: Core Product Placeholder Burn-down` | Stub list has no reused-frontend critical path without a named owner. | @@ -78,7 +78,7 @@ Open work after the current checkpoint: | Phase 2 | Route and controller parity audit | Doing | Ruby/Bundler unavailable, so Chatwoot route extraction currently uses static `routes.rb` fallback | | Phase 3 | Data and serializer parity | Doing | JSON fixture coverage is partial and still endpoint-family based | | Phase 4 | Enterprise feature completion | Doing | B7, B8, B9, B10, and B11 are in Review; B12 reused frontend smoke harnesses exist and optional live runs can expose follow-up slices | -| Phase 5 | Background jobs and integrations | Doing | P5.1/P5.2/P5.3/P5.4/P5.5/P5.6/P5.7/P5.8/P5.9/P5.10/P5.11/P5.12 durable worker, event dispatch, search indexing, automation delivery, macro, CSAT survey/template, SLA scan, contact export, inbound webhook persistence, outbound/provider delivery status, Captain document sync/crawl/response/embedding/Copilot/conversation responses, conversation maintenance, message status update, and account bulk-action cores are in Review; analytics aggregation and the B9.3 delayed automation action check remain open | +| Phase 5 | Background jobs and integrations | Doing | P5.1/P5.2/P5.3/P5.4/P5.5/P5.6/P5.7/P5.8/P5.9/P5.10/P5.11/P5.12 durable worker, event dispatch, search indexing, automation delivery, macro, CSAT survey/template, SLA scan, contact export, inbound webhook persistence, outbound/provider delivery status, Captain document sync/crawl/response/embedding/Copilot/conversation responses, conversation maintenance, message status update, and account bulk-action cores are in Review; P5.13a placeholder aggregation is in Review, while scheduled/cached rollups, timeseries index parity, and the B9.3 delayed automation action check remain open | | Phase 6 | Core placeholder burn-down | Doing | account/contact/conversation/message/inbox placeholder groups remain broad | | Phase 7 | Verification harness | Review | B12.1 boot/readiness, B12.2a API assertions, B12.2b browser smoke harness, B12.3a enterprise API assertions, and B12.3b enterprise browser route navigation exist; optional live Meilisearch/full-browser runs remain environment-dependent | @@ -88,7 +88,7 @@ This table is the shortest authoritative handoff view. If an older lower section | Priority | Workstream | Current state | Next checkpoint | Commit close rule | | --- | --- | --- | --- | --- | -| 1 | P5.13 reports/analytics | Frontend smoke harness exists; `analytics_service` still has placeholder aggregation paths. | Replace frontend-visible report placeholders with real scheduled/cached aggregations. | Report fixtures verify values, cache/freshness behavior, and no hidden placeholder JSON. | +| 1 | P5.13 reports/analytics | P5.13a derives live reports, bot reports, conversation summary, inbox-label matrix, first-response distribution, and outgoing-message counts from persisted rows; scheduled/cached rollups and `/reports` timeseries index parity remain. | Add scheduled or lazy cached rollup freshness/idempotency and route `GET /reports` to metric timeseries instead of summary. | Report fixtures verify timeseries values, cache/freshness behavior, and no hidden placeholder JSON. | | 2 | B9.3 delayed automation actions | Macro fan-out, webhook/transcript delivery, and CSAT jobs are durable; current-reference delayed automation action params still need a final check. | Queue any still-synchronous delayed automation action execution or close the row with reference evidence if no explicit delayed params exist. | Automation worker fixtures verify schedule time, retry, idempotency, and observable failure. | | 3 | Phase 2/3 drift | Tracked route parity is 0 missing for the current critical set; serializer fixtures remain partial. | Expand route/serializer fixtures when smoke or reference inspection exposes drift. | Regenerate parity artifacts and add endpoint-family fixture tests. | | 4 | Phase 6 placeholder audit | Widget/public/webhook critical placeholders are burned down; account/contact/conversation/message/inbox audit remains broad. | Run a fresh placeholder audit and assign every frontend-reachable stub to a tracked owner. | `rg` audit result is recorded and no reused-frontend blocker is ownerless. | @@ -103,7 +103,8 @@ These rows are the executable development plan from this point forward. A checkp | P5.11a Captain document crawl/schedule | `internal/service/captain_document_service.go`, `internal/service/captain_document_worker.go`, `internal/app/bootstrap.go` | `reference/chatwoot/enterprise/app/jobs/captain/documents/crawl_job.rb`, `schedule_syncs_job.rb`, `perform_sync_job.rb`, Firecrawl/simple parser jobs | Durable schedule/crawl producers and handlers with fakeable crawl/parser boundaries. Missing provider config is a failed `crawl_disabled` state, not placeholder success. | Review by `feat(captain): queue document crawl jobs`; focused worker tests prove enqueue, replay, account scope, idempotent scheduler, and disabled/failure states. | | P5.11b Captain response/embedding fan-out | Captain document/assistant-response services and repositories, Meilisearch/embedding boundaries | `response_builder_job.rb`, `enterprise/app/jobs/captain/llm/update_embedding_job.rb`, FAQ generator/embedding services | Queue FAQ response generation after successful document content changes, reset unedited responses, create/update assistant responses, and fan out embedding update work behind fakeable LLM gates. | Review by `feat(captain): queue response embedding jobs`; tests cover response reset/create, embedding-disabled retry, fake embedding success, account scope, and no external network in default tests. | | P5.11c Copilot and conversation response jobs | `internal/service/copilot_service.go`, `internal/service/copilot_response_worker.go`, `internal/service/captain_conversation_service.go`, `internal/service/message_service.go`, `internal/app/bootstrap.go` | `enterprise/app/jobs/captain/copilot/response_job.rb`, `enterprise/app/jobs/captain/conversation/response_builder_job.rb`, `enterprise/app/services/captain/copilot/chat_service.rb`, `enterprise/app/services/enterprise/message_templates/hook_execution_service.rb`, `enterprise/app/models/copilot_message.rb` | Queue assistant replies after Copilot user messages and Captain pending-conversation triggers. Persist assistant messages, enqueue Captain conversation replies/handoff messages, open handoff conversations, and keep fakeable provider disabled/failure states observable through durable retry. | Review by `feat(captain): queue copilot response jobs`; focused tests cover Copilot enqueue/persist/fallback/retry, Captain conversation enqueue/handoff/retry/non-pending skip, and service/worker/app package replay. | -| P5.13 analytics aggregation | `internal/service/analytics_service.go`, report handlers/services, worker bootstrap | Chatwoot report controllers/services used by dashboard analytics, CSAT/SLA reporting views | Replace frontend-visible placeholder report values with real scheduled or cached aggregations. Define freshness/idempotency rules for expensive rollups. | Report fixtures prove values are derived from persisted conversations/messages/CSAT/SLA rows; `rg` finds no frontend-visible placeholder report JSON. | +| P5.13a analytics placeholder burn-down | `internal/service/analytics_service.go`, `internal/service/analytics_query_helpers.go`, live/report handlers | Chatwoot `live_reports_controller.rb`, `reports_controller.rb`, `BotMetricsBuilder`, `InboxLabelMatrixBuilder`, `FirstResponseTimeDistributionBuilder`, `OutgoingMessagesCountBuilder` | Replace frontend-visible zero/empty placeholder responses for live conversations, grouped live conversations, bot summary/metrics, conversation summary, inbox-label matrix, first-response distribution, and outgoing-message counts with persisted conversation/message/reporting-event queries. | Review by `feat(reports): derive analytics aggregates`; focused service/handler tests prove non-zero values from persisted rows and `rg` finds no placeholder TODOs in these methods. | +| P5.13b scheduled/cached analytics | `internal/service/analytics_service.go`, `internal/service/reporting_rollup_service.go`, report handlers/services, worker bootstrap | Chatwoot report controllers/services used by dashboard analytics, `Reports::DataSource`, reporting rollup/backfill jobs | Wire scheduled or lazy cached rollup freshness/idempotency and route `GET /reports` to metric timeseries instead of the summary handler. Define freshness rules for expensive rollups. | Report fixtures prove timeseries values are derived from persisted conversations/messages/reporting events, rollups refresh idempotently, and no hidden placeholder report JSON remains. | | Phase 2/3 drift audit | `cmd/route_parity`, `docs/parity/*`, serializer tests | `reference/chatwoot/config/routes.rb`, controller Jbuilder views, reused frontend API clients | Convert any smoke/reference mismatch into a named route, controller, or serializer slice. Static route extraction remains acceptable until Ruby/Bundler is available. | Regenerated route parity shows 0 missing tracked frontend routes; new serializer fixtures cover the drift. | | Phase 6 placeholder burn-down | Account/contact/conversation/message/inbox handlers and services | Matching reference controllers/Jbuilder views plus reused frontend screens | Re-run placeholder audit and assign every frontend-reachable stub to a specific owner. Burn down the highest-impact stubs before broad feature expansion. | `rg` placeholder audit is recorded here; no reused-frontend critical path is ownerless. | | B12 live smoke | `scripts/parity_frontend_smoke.sh`, `docs/parity/frontend_smoke_report.md`, `cmd/gochat` | Reused `reference/chatwoot` Vite frontend, dashboard route/API clients | Run optional live API/browser/enterprise smoke with PostgreSQL, Redis, Meilisearch, GoChat, Vite, and Chrome. Convert failures into named rows above. | Smoke report records command, environment, pass/fail, artifacts, and linked follow-up owners. | @@ -139,6 +140,7 @@ This ledger records the committed parity checkpoints that future slices should b | Commit | Scope | Verification summary | Follow-up state | | --- | --- | --- | --- | +| `feat(reports): derive analytics aggregates` | Advances P5.13a by replacing frontend-visible analytics placeholder responses with persisted aggregations. Live report conversation metrics now count open/unattended/unassigned/pending conversations with team filtering; grouped live reports return assignee/team grouped counts; bot summary/metrics, conversation summary, inbox-label matrix, first-response distribution, and outgoing-message counts are derived from conversations, messages, labels, agent-bot bindings, and reporting events instead of fixed zero/empty JSON. | `go test ./internal/service -run 'Analytics' -count=1`; `go test ./internal/handler/api/v1 -run 'Analytics\|LiveReport' -count=1`; `go test ./internal/service ./internal/handler/api/v1 ./internal/worker ./internal/app -count=1`; `go test ./...`; `git diff --check`; full verification recorded in the P5.13 section. | P5.13a moves to Review; continue P5.13b scheduled/cached rollup freshness and `/reports` timeseries index parity, then B9.3 delayed automation action check. | | `feat(captain): queue copilot response jobs` | Completes P5.11c with durable Chatwoot `Captain::Copilot::ResponseJob` and `Captain::Conversation::ResponseBuilderJob` equivalents. Copilot thread/message creation now persists the user message and enqueues `captain:copilot_response` when a WorkerPool is configured, while worker replay reloads the account/user/thread/message scope and persists assistant replies through a fakeable backend or the existing no-provider fallback. Incoming pending conversation messages for Captain-enabled inboxes now enqueue `captain:conversation_response_builder`; replay collects public incoming/outgoing history, creates Captain outgoing replies, enqueues provider send-reply, and opens the conversation with a handoff message when the backend requests handoff. | `go test ./internal/service -run 'CopilotResponse\|CaptainConversation\|MessageService' -count=1`; `go test ./internal/service ./internal/worker ./internal/app -count=1`; `go test ./...`; `git diff --check`; full verification recorded in the P5.11 section. | P5.11 moves to Review; continue P5.13 analytics aggregation, then Phase 2/3 drift and Phase 6 placeholder audits as smoke exposes gaps. | | `feat(captain): queue response embedding jobs` | Advances P5.11b with durable Captain document response building and embedding update fan-out. Successful document sync/parser content updates enqueue `captain:document_response_builder`, worker replay resets only unedited document responses, preserves edited responses, creates approved `Captain::Document` assistant responses from a fakeable FAQ backend, and enqueues `captain:llm_update_embedding` jobs for created responses. Embedding replay reloads account-scoped responses, uses a fakeable embedding backend or configured LLM provider, and surfaces missing provider config as retryable worker failures. | `go test ./internal/service -run 'CaptainDocumentService\|EnqueueCaptainDocumentScheduleSyncs' -count=1`; `go test ./internal/service ./internal/worker ./internal/app -count=1`; `go test ./...`; `git diff --check`; full verification recorded in the P5.11 section. | P5.11b moves to Review; continue P5.11c Copilot/conversation response jobs, then P5.13 analytics aggregation. | | `feat(captain): queue document crawl jobs` | Advances P5.11a with durable Captain document crawl, simple-page parser, and auto-sync scheduler jobs. Document create/crawl requests enqueue `captain:document_crawl`, crawl replay uses a fakeable crawl backend to enqueue normalized `captain:document_page_crawl_parse` jobs, parser replay creates or updates account-scoped document content through a fakeable parser backend, and the `captain:documents_schedule_syncs` root job scans stale synced/failed/syncing documents for accounts with `captain_document_auto_sync` enabled before enqueueing `captain:document_sync` work with daily idempotency. Bootstrap seeds the scheduler after Captain document services are wired. | `go test ./internal/service -run 'CaptainDocumentService\|EnqueueCaptainDocumentScheduleSyncs' -count=1`; `go test ./internal/service ./internal/worker ./internal/app -count=1`; `go test ./...`; `git diff --check`; full verification recorded in the P5.11 section. | P5.11a moves to Review; continue P5.11b response/embedding fan-out, then P5.11c Copilot/conversation response jobs. | @@ -1672,7 +1674,7 @@ Tracking table: | P5.10 | Queue outbound message delivery and delivery-status updates. | `send_reply_job.rb`, provider delivery/status jobs | message send/channel services, delivery status handler | Outgoing message creation and provider delivery are separated; retries update message/delivery status exactly once. | Review by `feat(messages): queue send replies` and `feat(messages): queue delivery statuses` | | P5.11 | Queue Captain document sync, crawl, response building, embeddings, and Copilot responses. | Captain document/crawl/response/embedding/Copilot/conversation jobs | `internal/service/captain_document_service.go`, `internal/service/copilot_service.go`, `internal/service/captain_conversation_service.go`, Captain/Copilot services | Existing fakeable disabled/failure gates run under durable jobs; document statuses, Copilot message persistence, and Captain conversation replies survive worker restart. | Review by `feat(captain): queue document syncs`, `feat(captain): queue document crawl jobs`, `feat(captain): queue response embedding jobs`, and `feat(captain): queue copilot response jobs` | | P5.12 | Queue conversation maintenance jobs. | `trigger_scheduled_items_job.rb`, `campaigns/trigger_oneoff_campaign_job.rb`, `conversations/resolution_job.rb`, `reopen_snoozed_conversations_job.rb`, `update_message_status_job.rb`, `bulk_actions_job.rb` | conversation service/handlers | Auto-resolution, snooze reopen, status updates, and bulk actions are scheduled/retryable with idempotent tests. | Review by `feat(conversations): queue maintenance jobs`, `feat(conversations): queue message status updates`, and `feat(conversations): queue bulk actions` | -| P5.13 | Replace placeholder analytics/report builders that need background aggregation. | reporting jobs/services and report controllers | `internal/service/analytics_service.go`, reporting services | Frontend-visible reports no longer use placeholder values; any expensive aggregation is scheduled or cached with freshness rules. | Todo | +| P5.13 | Replace placeholder analytics/report builders that need background aggregation. | reporting jobs/services and report controllers | `internal/service/analytics_service.go`, `internal/service/analytics_query_helpers.go`, reporting services | P5.13a derives live/bot/conversation summary/matrix/distribution/outgoing-count report values from persisted rows; remaining expensive aggregation should be scheduled or cached with freshness rules. | Doing: placeholder burn-down Review by `feat(reports): derive analytics aggregates`; scheduled/cached rollups and `/reports` timeseries index parity Todo | P5.1 current checkpoint: @@ -1898,6 +1900,26 @@ env TMPDIR=/home/rogee/Projects/gochat/.tmp/test-tmp GOCACHE=/tmp/gochat-gocache git diff --check ``` +P5.13 current checkpoint: + +- P5.13a replaces the most visible analytics/report placeholder values with persisted queries instead of fixed zero or empty JSON. +- Live conversation metrics now count `open`, `unattended`, `unassigned`, and `pending` from `conversations`, including Chatwoot-style unattended semantics and `team_id` filtering. +- Grouped live reports now group open conversations by `assignee_id` or `team_id` and return the matching Chatwoot key plus open/unattended/unassigned counts. +- Bot summary and bot metrics now derive bot resolutions/handoffs from `reporting_events`, exclude handoff conversations from bot resolutions, and calculate bot conversation/message counts and rates from active `agent_bot_inboxes`. +- Conversation summary now derives conversation, incoming-message, outgoing-message, resolution, first-response, resolution-time, and reply-time values from persisted conversations/messages/reporting events. +- Inbox-label matrix now uses `inboxes`, `tags`, and `conversation_labels`; first-response distribution buckets `first_response` events by inbox channel type; outgoing-message counts group by agent, team, inbox, or label. +- Remaining P5.13 work: scheduled or lazy cached rollup freshness/idempotency and `/api/v2/accounts/:account_id/reports` timeseries index parity. + +P5.13 verification: + +```bash +env GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/service -run 'Analytics' -count=1 +env GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/handler/api/v1 -run 'Analytics\|LiveReport' -count=1 +env GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/service ./internal/handler/api/v1 ./internal/worker ./internal/app -count=1 +env TMPDIR=/home/rogee/Projects/gochat/.tmp/test-tmp GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./... +git diff --check +``` + ## Phase 6: Core Product Placeholder Burn-down Status: doing. @@ -1999,6 +2021,7 @@ Verification milestone gates: ## Progress Log +- 2026-06-05: P5.13a analytics placeholder burn-down checkpoint prepared as `feat(reports): derive analytics aggregates`; live report conversation metrics and grouped metrics now read persisted conversations, bot summary/metrics read reporting events plus active agent-bot inbox bindings, conversation summary reads conversations/messages/reporting events, inbox-label matrix reads inbox/tag/conversation-label rows, first-response distribution buckets reporting events by channel, and outgoing-message counts group by agent/team/inbox/label. Focused analytics service tests, analytics/live handler tests, service/handler/worker/app package tests, full `go test ./...`, and `git diff --check` passed. Remaining P5.13 follow-up is scheduled/cached rollup freshness and `/reports` timeseries index parity. - 2026-06-05: P5.11c Copilot/conversation response checkpoint prepared as `feat(captain): queue copilot response jobs`; Copilot thread/message creation now enqueues `captain:copilot_response` after persisting the user message, worker replay persists assistant replies through a fakeable backend or the existing no-provider fallback, and backend failures surface as retryable jobs. Pending Captain-enabled incoming conversations now enqueue `captain:conversation_response_builder`, replay creates Captain outgoing replies, queues provider send-reply, and opens conversations on handoff. Focused Copilot/Captain conversation worker tests, service/worker/app package tests, full `go test ./...`, and `git diff --check` passed. P5.11 moves to Review; next slice is P5.13 analytics aggregation. - 2026-06-05: P5.11b Captain response/embedding checkpoint prepared as `feat(captain): queue response embedding jobs`; successful document sync/parser content updates now enqueue `captain:document_response_builder`, response-builder replay resets only unedited document responses, preserves edited responses, creates approved `Captain::Document` assistant responses through a fakeable FAQ backend, and fans out `captain:llm_update_embedding` jobs. Embedding replay reloads account-scoped responses and uses a fakeable embedding backend or configured LLM provider, with missing providers surfacing as retryable worker failures. Focused Captain document worker tests, service/worker/app package tests, full `go test ./...`, and `git diff --check` passed. Remaining P5.11 follow-up is Copilot/conversation response jobs. - 2026-06-05: P5.11a Captain document crawl/schedule checkpoint prepared as `feat(captain): queue document crawl jobs`; document create/crawl requests now enqueue `captain:document_crawl`, crawl replay fans out normalized `captain:document_page_crawl_parse` parser jobs through fakeable crawl/parser boundaries, parser replay creates or updates account-scoped documents with synced content/fingerprints, and `captain:documents_schedule_syncs` scans stale completed documents for `captain_document_auto_sync` accounts to enqueue `captain:document_sync` jobs with daily idempotency. Focused Captain document worker tests, service/worker/app package tests, full `go test ./...`, and `git diff --check` passed. Remaining P5.11 follow-up is now Copilot/conversation response jobs after P5.11b. diff --git a/internal/handler/api/v1/analytics_handler.go b/internal/handler/api/v1/analytics_handler.go index 8c7a9e15..b847d47d 100644 --- a/internal/handler/api/v1/analytics_handler.go +++ b/internal/handler/api/v1/analytics_handler.go @@ -187,6 +187,7 @@ func (h *AnalyticsHandler) ConversationTraffic(c *gin.Context) { response.OK(c, result) } + // BotSummary returns bot-level summary metrics. // GET /api/v1/accounts/:account_id/reports/bot_summary // Reference: Chatwoot reports#bot_summary @@ -226,12 +227,7 @@ func (h *AnalyticsHandler) Conversations(c *gin.Context) { return } - since, until, ok := parseDateRange(c) - if !ok { - return - } - - result, err := h.svc.GetConversationsByType(c.Request.Context(), accountID, reportType, since, until) + result, err := h.svc.GetConversationsByType(c.Request.Context(), accountID, reportType, time.Time{}, time.Time{}) if err != nil { applogger.L().Errorf("Conversations report: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to generate conversations report") @@ -341,8 +337,13 @@ func (h *AnalyticsHandler) OutgoingMessagesCount(c *gin.Context) { if !ok { return } + groupBy := c.Query("group_by") + if groupBy != "agent" && groupBy != "team" && groupBy != "inbox" && groupBy != "label" { + response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrBadRequest, "invalid group_by") + return + } - result, err := h.svc.GetOutgoingMessagesCount(c.Request.Context(), accountID, since, until) + result, err := h.svc.GetOutgoingMessagesCountGrouped(c.Request.Context(), accountID, since, until, groupBy) if err != nil { applogger.L().Errorf("Outgoing messages count report: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to generate outgoing messages count") diff --git a/internal/handler/api/v1/live_report_handler.go b/internal/handler/api/v1/live_report_handler.go index 529a9f69..35e79139 100644 --- a/internal/handler/api/v1/live_report_handler.go +++ b/internal/handler/api/v1/live_report_handler.go @@ -2,6 +2,7 @@ package v1 import ( "net/http" + "strconv" "github.com/gin-gonic/gin" @@ -30,19 +31,23 @@ func (h *LiveReportHandler) ConversationMetrics(c *gin.Context) { return } - // Chatwoot supports team_id filter - teamIDStr := c.Query("team_id") + teamID := uint(0) + if teamIDStr := c.Query("team_id"); teamIDStr != "" { + parsed, err := strconv.ParseUint(teamIDStr, 10, 64) + if err != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid team_id") + return + } + teamID = uint(parsed) + } - result, err := h.svc.GetConversationMetrics(c.Request.Context(), accountID) + result, err := h.svc.GetConversationMetricsForTeam(c.Request.Context(), accountID, teamID) if err != nil { applogger.L().Errorf("Live conversation metrics: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to get conversation metrics") return } - // Chatwoot also filters by team_id if provided — TODO: implement team filter - _ = teamIDStr - response.OK(c, result) } @@ -72,4 +77,4 @@ func (h *LiveReportHandler) GroupedConversationMetrics(c *gin.Context) { } response.OK(c, result) -} \ No newline at end of file +} diff --git a/internal/repository/reporting_event_repo.go b/internal/repository/reporting_event_repo.go index d5fa601b..c7214757 100644 --- a/internal/repository/reporting_event_repo.go +++ b/internal/repository/reporting_event_repo.go @@ -17,6 +17,13 @@ func NewReportingEventRepo(db *gorm.DB) *ReportingEventRepo { return &ReportingEventRepo{db: db} } +func (r *ReportingEventRepo) DB() *gorm.DB { + if r == nil { + return nil + } + return r.db +} + func (r *ReportingEventRepo) Create(ctx context.Context, event *model.ReportingEvent) error { return r.db.WithContext(ctx).Create(event).Error } @@ -75,4 +82,4 @@ func (r *ReportingEventRepo) FindByAccountIDAndTimeRange(ctx context.Context, ac Where("account_id = ? AND event_start_time >= ? AND event_end_time <= ?", accountID, since, until). Find(&events).Error return events, err -} \ No newline at end of file +} diff --git a/internal/repository/reporting_events_rollup_repo.go b/internal/repository/reporting_events_rollup_repo.go index 3dd44587..b6d98f4e 100644 --- a/internal/repository/reporting_events_rollup_repo.go +++ b/internal/repository/reporting_events_rollup_repo.go @@ -17,6 +17,13 @@ func NewReportingEventsRollupRepo(db *gorm.DB) *ReportingEventsRollupRepo { return &ReportingEventsRollupRepo{db: db} } +func (r *ReportingEventsRollupRepo) DB() *gorm.DB { + if r == nil { + return nil + } + return r.db +} + func (r *ReportingEventsRollupRepo) Create(ctx context.Context, rollup *model.ReportingEventsRollup) error { return r.db.WithContext(ctx).Create(rollup).Error } @@ -82,4 +89,4 @@ func (r *ReportingEventsRollupRepo) FindByAccountAndDateRangeWithDimension(ctx c } err := query.Find(&rollups).Error return rollups, err -} \ No newline at end of file +} diff --git a/internal/service/analytics_p513_test.go b/internal/service/analytics_p513_test.go new file mode 100644 index 00000000..11d4b6f1 --- /dev/null +++ b/internal/service/analytics_p513_test.go @@ -0,0 +1,149 @@ +package service + +import ( + "context" + "testing" + "time" + + "github.com/gochat/gochat/internal/model" + "github.com/gochat/gochat/internal/repository" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func setupAnalyticsP513Test(t *testing.T) (*gorm.DB, *AnalyticsService, *model.Account, *model.Inbox, *model.Contact, *model.User, *model.Team) { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate( + &model.Account{}, + &model.User{}, + &model.AccountUser{}, + &model.Team{}, + &model.Inbox{}, + &model.Contact{}, + &model.Conversation{}, + &model.Message{}, + &model.ReportingEvent{}, + &model.ReportingEventsRollup{}, + &model.AgentBot{}, + &model.AgentBotInbox{}, + &model.Tag{}, + &model.ConversationLabel{}, + )) + t.Cleanup(func() { + sqlDB, _ := db.DB() + sqlDB.Close() + }) + account := &model.Account{Name: "Analytics"} + require.NoError(t, db.Create(account).Error) + user := &model.User{AccountID: account.ID, Name: "Agent", Email: "agent@example.com", Password: "secret", Active: true} + require.NoError(t, db.Create(user).Error) + require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: user.ID, Role: string(model.AccountUserRoleAgent)}).Error) + team := &model.Team{AccountID: account.ID, Name: "Support"} + require.NoError(t, db.Create(team).Error) + inbox := &model.Inbox{AccountID: account.ID, Name: "Web", ChannelType: "web_widget", ChannelID: 1, Enabled: true} + require.NoError(t, db.Create(inbox).Error) + contact := &model.Contact{AccountID: account.ID, Name: "Customer", Email: "customer@example.com"} + require.NoError(t, db.Create(contact).Error) + svc := NewAnalyticsService(repository.NewReportingEventRepo(db), repository.NewReportingEventsRollupRepo(db)) + return db, svc, account, inbox, contact, user, team +} + +func TestAnalyticsLiveConversationMetricsAreDerivedFromConversations(t *testing.T) { + db, svc, account, inbox, contact, user, team := setupAnalyticsP513Test(t) + now := int64(1760000000) + firstReply := now - 60 + assigned := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, AssigneeID: &user.ID, TeamID: &team.ID, Status: string(model.ConversationStatusOpen), ChannelType: inbox.ChannelType, Channel: inbox.ChannelType} + unassigned := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, TeamID: &team.ID, Status: string(model.ConversationStatusOpen), ChannelType: inbox.ChannelType, Channel: inbox.ChannelType, FirstReplyCreatedAt: &firstReply} + pending := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, Status: string(model.ConversationStatusPending), ChannelType: inbox.ChannelType, Channel: inbox.ChannelType} + otherAccount := &model.Account{Name: "Other"} + require.NoError(t, db.Create(otherAccount).Error) + other := &model.Conversation{AccountID: otherAccount.ID, InboxID: inbox.ID, ContactID: contact.ID, Status: string(model.ConversationStatusOpen), ChannelType: inbox.ChannelType, Channel: inbox.ChannelType} + require.NoError(t, db.Create(assigned).Error) + require.NoError(t, db.Create(unassigned).Error) + require.NoError(t, db.Create(pending).Error) + require.NoError(t, db.Create(other).Error) + + metrics, err := svc.GetConversationMetrics(context.Background(), account.ID) + require.NoError(t, err) + assert.Equal(t, int64(2), metrics.OpenCount) + assert.Equal(t, int64(1), metrics.UnattendedCount) + assert.Equal(t, int64(1), metrics.UnassignedCount) + assert.Equal(t, int64(1), metrics.PendingCount) + + teamMetrics, err := svc.GetConversationMetricsForTeam(context.Background(), account.ID, team.ID) + require.NoError(t, err) + assert.Equal(t, int64(2), teamMetrics.OpenCount) + assert.Equal(t, int64(0), teamMetrics.PendingCount) + + grouped, err := svc.GetGroupedConversationMetrics(context.Background(), account.ID, "assignee_id") + require.NoError(t, err) + require.Len(t, grouped, 2) + assert.Equal(t, int64(1), grouped[0]["open"]) + assert.Nil(t, grouped[0]["assignee_id"]) + assert.Equal(t, user.ID, grouped[1]["assignee_id"]) + assert.Equal(t, int64(1), grouped[1]["open"]) +} + +func TestAnalyticsReportsUsePersistedConversationMessageAndEventRows(t *testing.T) { + db, svc, account, inbox, contact, user, team := setupAnalyticsP513Test(t) + since := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + until := since.Add(24 * time.Hour) + resolvedAt := since.Add(3 * time.Hour) + conv := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, AssigneeID: &user.ID, TeamID: &team.ID, Status: string(model.ConversationStatusResolved), ResolvedAt: &resolvedAt, ChannelType: inbox.ChannelType, Channel: inbox.ChannelType} + require.NoError(t, db.Create(conv).Error) + require.NoError(t, db.Model(conv).Updates(map[string]interface{}{"created_at": since.Add(time.Hour)}).Error) + senderID := user.ID + require.NoError(t, db.Create(&model.Message{Base: model.Base{CreatedAt: since.Add(2 * time.Hour)}, AccountID: account.ID, ConversationID: conv.ID, InboxID: inbox.ID, SenderID: &senderID, SenderType: "User", MessageType: string(model.MessageTypeOutgoing), Content: "hello"}).Error) + require.NoError(t, db.Create(&model.Message{Base: model.Base{CreatedAt: since.Add(90 * time.Minute)}, AccountID: account.ID, ConversationID: conv.ID, InboxID: inbox.ID, MessageType: string(model.MessageTypeIncoming), Content: "hi"}).Error) + bot := &model.AgentBot{AccountID: &account.ID, Name: "Bot"} + require.NoError(t, db.Create(bot).Error) + require.NoError(t, db.Create(&model.AgentBotInbox{AccountID: &account.ID, AgentBotID: bot.ID, InboxID: inbox.ID, Status: model.AgentBotInboxActive}).Error) + tag := &model.Tag{AccountID: account.ID, Name: "vip"} + require.NoError(t, db.Create(tag).Error) + require.NoError(t, db.Create(&model.ConversationLabel{AccountID: account.ID, ConversationID: conv.ID, TagID: tag.ID}).Error) + require.NoError(t, db.Create(&model.ReportingEvent{Base: model.Base{CreatedAt: since.Add(30 * time.Minute)}, AccountID: account.ID, Name: model.MetricNameFirstResponse, Value: 1800, ConversationID: &conv.ID, InboxID: &inbox.ID, UserID: &user.ID, EventStartTime: since, EventEndTime: since.Add(30 * time.Minute)}).Error) + require.NoError(t, db.Create(&model.ReportingEvent{Base: model.Base{CreatedAt: since.Add(time.Hour)}, AccountID: account.ID, Name: "conversation_bot_resolved", Value: 1, ConversationID: &conv.ID, InboxID: &inbox.ID, EventStartTime: since, EventEndTime: since.Add(time.Hour)}).Error) + + summary, err := svc.GetConversationsSummary(context.Background(), account.ID, since, until) + require.NoError(t, err) + summaryMap := summary.(map[string]interface{}) + assert.Equal(t, int64(1), summaryMap["conversations_count"]) + assert.Equal(t, int64(1), summaryMap["incoming_messages_count"]) + assert.Equal(t, int64(1), summaryMap["outgoing_messages_count"]) + assert.Equal(t, int64(1), summaryMap["resolutions_count"]) + assert.Equal(t, 1800.0, summaryMap["avg_first_response_time"]) + + botSummary, err := svc.GetBotSummary(context.Background(), account.ID, since, until) + require.NoError(t, err) + assert.Equal(t, int64(1), botSummary.BotResolutionsCount) + assert.Equal(t, int64(0), botSummary.BotHandoffsCount) + assert.NotNil(t, botSummary.Previous) + + botMetrics, err := svc.GetBotMetrics(context.Background(), account.ID, since, until) + require.NoError(t, err) + botMap := botMetrics.(map[string]interface{}) + assert.Equal(t, int64(1), botMap["conversation_count"]) + assert.Equal(t, int64(1), botMap["message_count"]) + assert.Equal(t, 100, botMap["resolution_rate"]) + + distribution, err := svc.GetFirstResponseTimeDistribution(context.Background(), account.ID, since, until) + require.NoError(t, err) + distMap := distribution.(map[string]map[string]int64) + assert.Equal(t, int64(1), distMap["web_widget"]["0-1h"]) + + outgoing, err := svc.GetOutgoingMessagesCountGrouped(context.Background(), account.ID, since, until, "inbox") + require.NoError(t, err) + outRows := outgoing.([]map[string]interface{}) + require.Len(t, outRows, 1) + assert.Equal(t, inbox.ID, outRows[0]["id"]) + assert.Equal(t, int64(1), outRows[0]["outgoing_messages_count"]) + + matrix, err := svc.GetInboxLabelMatrix(context.Background(), account.ID) + require.NoError(t, err) + matrixMap := matrix.(map[string]interface{}) + assert.Equal(t, [][]int64{{1}}, matrixMap["matrix"]) +} diff --git a/internal/service/analytics_query_helpers.go b/internal/service/analytics_query_helpers.go new file mode 100644 index 00000000..dd38b838 --- /dev/null +++ b/internal/service/analytics_query_helpers.go @@ -0,0 +1,452 @@ +package service + +import ( + "context" + "errors" + "sort" + "strings" + "time" + + "github.com/gochat/gochat/internal/model" + "gorm.io/gorm" +) + +func (s *AnalyticsService) analyticsDB() (*gorm.DB, error) { + if s == nil || s.db == nil { + return nil, errors.New("analytics database is required") + } + return s.db, nil +} + +func (s *AnalyticsService) liveConversationMetrics(ctx context.Context, accountID uint, teamID uint) (*ConversationMetrics, error) { + db, err := s.analyticsDB() + if err != nil { + return nil, err + } + base := func() *gorm.DB { + q := db.WithContext(ctx).Model(&model.Conversation{}).Where("account_id = ?", accountID) + if teamID > 0 { + q = q.Where("team_id = ?", teamID) + } + return q + } + var result ConversationMetrics + if err := base().Where("status = ?", string(model.ConversationStatusOpen)).Count(&result.OpenCount).Error; err != nil { + return nil, err + } + if err := base().Where("status = ? AND (first_reply_created_at IS NULL OR waiting_since IS NOT NULL)", string(model.ConversationStatusOpen)).Count(&result.UnattendedCount).Error; err != nil { + return nil, err + } + if err := base().Where("status = ? AND assignee_id IS NULL", string(model.ConversationStatusOpen)).Count(&result.UnassignedCount).Error; err != nil { + return nil, err + } + if err := base().Where("status = ?", string(model.ConversationStatusPending)).Count(&result.PendingCount).Error; err != nil { + return nil, err + } + return &result, nil +} + +func (s *AnalyticsService) groupedLiveConversationMetrics(ctx context.Context, accountID uint, groupBy string) ([]map[string]interface{}, error) { + if groupBy != "team_id" && groupBy != "assignee_id" { + return nil, errors.New("invalid group_by") + } + db, err := s.analyticsDB() + if err != nil { + return nil, err + } + type row struct { + GroupID *uint + Count int64 + } + load := func(where string) (map[uint]int64, error) { + var rows []row + if err := db.WithContext(ctx).Model(&model.Conversation{}). + Select(groupBy+" AS group_id, COUNT(*) AS count"). + Where("account_id = ? AND status = ? "+where, accountID, string(model.ConversationStatusOpen)). + Group(groupBy). + Scan(&rows).Error; err != nil { + return nil, err + } + counts := map[uint]int64{} + for _, row := range rows { + if row.GroupID == nil { + counts[0] += row.Count + continue + } + counts[*row.GroupID] += row.Count + } + return counts, nil + } + open, err := load("") + if err != nil { + return nil, err + } + unattended, err := load("AND (first_reply_created_at IS NULL OR waiting_since IS NOT NULL)") + if err != nil { + return nil, err + } + unassigned, err := load("AND assignee_id IS NULL") + if err != nil { + return nil, err + } + ids := make([]uint, 0, len(open)) + for id := range open { + ids = append(ids, id) + } + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + result := make([]map[string]interface{}, 0, len(ids)) + for _, id := range ids { + metric := map[string]interface{}{ + groupBy: id, + "open": open[id], + "unattended": unattended[id], + "unassigned": unassigned[id], + } + if id == 0 { + metric[groupBy] = nil + } + result = append(result, metric) + } + return result, nil +} + +func (s *AnalyticsService) botSummaryCounts(ctx context.Context, accountID uint, since, until time.Time) (*BotSummaryResponse, error) { + db, err := s.analyticsDB() + if err != nil { + return nil, err + } + var handoffIDs []uint + if err := db.WithContext(ctx).Model(&model.ReportingEvent{}). + Where("account_id = ? AND name IN ? AND created_at >= ? AND created_at < ? AND conversation_id IS NOT NULL", accountID, []string{"conversation_bot_handoff", model.MetricNameBotHandoffsCount}, since, until). + Distinct("conversation_id").Pluck("conversation_id", &handoffIDs).Error; err != nil { + return nil, err + } + result := &BotSummaryResponse{} + handoffQ := db.WithContext(ctx).Model(&model.ReportingEvent{}). + Where("account_id = ? AND name IN ? AND created_at >= ? AND created_at < ?", accountID, []string{"conversation_bot_handoff", model.MetricNameBotHandoffsCount}, since, until) + if err := handoffQ.Distinct("conversation_id").Count(&result.BotHandoffsCount).Error; err != nil { + return nil, err + } + resolvedQ := db.WithContext(ctx).Model(&model.ReportingEvent{}). + Where("account_id = ? AND name IN ? AND created_at >= ? AND created_at < ?", accountID, []string{"conversation_bot_resolved", model.MetricNameBotResolutionsCount}, since, until) + if len(handoffIDs) > 0 { + resolvedQ = resolvedQ.Where("conversation_id IS NULL OR conversation_id NOT IN ?", handoffIDs) + } + if err := resolvedQ.Distinct("conversation_id").Count(&result.BotResolutionsCount).Error; err != nil { + return nil, err + } + return result, nil +} + +func (s *AnalyticsService) conversationMetricsByType(ctx context.Context, accountID uint, reportType string) (interface{}, error) { + switch reportType { + case "account": + return s.liveConversationMetrics(ctx, accountID, 0) + case "agent": + return s.groupedLiveConversationMetrics(ctx, accountID, "assignee_id") + case "team": + return s.groupedLiveConversationMetrics(ctx, accountID, "team_id") + default: + return s.liveConversationMetrics(ctx, accountID, 0) + } +} + +func (s *AnalyticsService) conversationSummary(ctx context.Context, accountID uint, since, until time.Time) (map[string]interface{}, error) { + db, err := s.analyticsDB() + if err != nil { + return nil, err + } + countConversations := func(where string, args ...interface{}) (int64, error) { + var count int64 + q := db.WithContext(ctx).Model(&model.Conversation{}).Where("account_id = ?", accountID).Where(where, args...) + err := q.Count(&count).Error + return count, err + } + countMessages := func(messageType model.MessageType) (int64, error) { + var count int64 + err := db.WithContext(ctx).Model(&model.Message{}). + Where("account_id = ? AND message_type = ? AND created_at >= ? AND created_at < ?", accountID, string(messageType), since, until). + Count(&count).Error + return count, err + } + conversations, err := countConversations("created_at >= ? AND created_at < ?", since, until) + if err != nil { + return nil, err + } + incoming, err := countMessages(model.MessageTypeIncoming) + if err != nil { + return nil, err + } + outgoing, err := countMessages(model.MessageTypeOutgoing) + if err != nil { + return nil, err + } + resolutions, err := countConversations("resolved_at IS NOT NULL AND resolved_at >= ? AND resolved_at < ?", since, until) + if err != nil { + return nil, err + } + firstResponse, _ := s.averageEventValue(ctx, accountID, []string{model.MetricNameFirstResponse}, since, until) + resolutionTime, _ := s.averageEventValue(ctx, accountID, []string{"conversation_resolved", model.MetricNameResolutionTime}, since, until) + replyTime, _ := s.averageEventValue(ctx, accountID, []string{model.MetricNameReplyTime}, since, until) + return map[string]interface{}{ + "conversations_count": conversations, + "incoming_messages_count": incoming, + "outgoing_messages_count": outgoing, + "avg_first_response_time": firstResponse, + "avg_resolution_time": resolutionTime, + "resolutions_count": resolutions, + "reply_time": replyTime, + }, nil +} + +func (s *AnalyticsService) botMetrics(ctx context.Context, accountID uint, since, until time.Time) (map[string]interface{}, error) { + db, err := s.analyticsDB() + if err != nil { + return nil, err + } + var inboxIDs []uint + if db.Migrator().HasTable(&model.AgentBotInbox{}) { + if err := db.WithContext(ctx).Model(&model.AgentBotInbox{}). + Where("status = ?", model.AgentBotInboxActive). + Where("account_id = ? OR account_id IS NULL", accountID). + Distinct("inbox_id").Pluck("inbox_id", &inboxIDs).Error; err != nil { + return nil, err + } + } + conversationCount := int64(0) + messageCount := int64(0) + if len(inboxIDs) > 0 { + if err := db.WithContext(ctx).Model(&model.Conversation{}). + Where("account_id = ? AND inbox_id IN ? AND created_at >= ? AND created_at < ?", accountID, inboxIDs, since, until). + Count(&conversationCount).Error; err != nil { + return nil, err + } + if err := db.WithContext(ctx).Model(&model.Message{}). + Where("account_id = ? AND inbox_id IN ? AND message_type = ? AND created_at >= ? AND created_at < ?", accountID, inboxIDs, string(model.MessageTypeOutgoing), since, until). + Count(&messageCount).Error; err != nil { + return nil, err + } + } + summary, err := s.botSummaryCounts(ctx, accountID, since, until) + if err != nil { + return nil, err + } + resolutionRate := 0 + handoffRate := 0 + if conversationCount > 0 { + resolutionRate = int(float64(summary.BotResolutionsCount) / float64(conversationCount) * 100) + handoffRate = int(float64(summary.BotHandoffsCount) / float64(conversationCount) * 100) + } + return map[string]interface{}{ + "conversation_count": conversationCount, + "message_count": messageCount, + "resolution_rate": resolutionRate, + "handoff_rate": handoffRate, + }, nil +} + +func (s *AnalyticsService) inboxLabelMatrix(ctx context.Context, accountID uint) (map[string]interface{}, error) { + db, err := s.analyticsDB() + if err != nil { + return nil, err + } + var inboxes []model.Inbox + if err := db.WithContext(ctx).Where("account_id = ?", accountID).Order("name ASC").Find(&inboxes).Error; err != nil { + return nil, err + } + var tags []model.Tag + if err := db.WithContext(ctx).Where("account_id = ?", accountID).Order("name ASC").Find(&tags).Error; err != nil { + return nil, err + } + type countRow struct { + InboxID uint + TagID uint + Count int64 + } + var rows []countRow + if len(inboxes) > 0 && len(tags) > 0 { + if err := db.WithContext(ctx).Table("conversation_labels"). + Select("conversations.inbox_id AS inbox_id, conversation_labels.tag_id AS tag_id, COUNT(*) AS count"). + Joins("INNER JOIN conversations ON conversations.id = conversation_labels.conversation_id"). + Where("conversation_labels.account_id = ?", accountID). + Group("conversations.inbox_id, conversation_labels.tag_id"). + Scan(&rows).Error; err != nil { + return nil, err + } + } + counts := map[uint]map[uint]int64{} + for _, row := range rows { + if counts[row.InboxID] == nil { + counts[row.InboxID] = map[uint]int64{} + } + counts[row.InboxID][row.TagID] = row.Count + } + inboxPayload := make([]map[string]interface{}, 0, len(inboxes)) + for _, inbox := range inboxes { + inboxPayload = append(inboxPayload, map[string]interface{}{"id": inbox.ID, "name": inbox.Name}) + } + labelPayload := make([]map[string]interface{}, 0, len(tags)) + for _, tag := range tags { + labelPayload = append(labelPayload, map[string]interface{}{"id": tag.ID, "title": tag.Name}) + } + matrix := make([][]int64, 0, len(inboxes)) + for _, inbox := range inboxes { + row := make([]int64, 0, len(tags)) + for _, tag := range tags { + row = append(row, counts[inbox.ID][tag.ID]) + } + matrix = append(matrix, row) + } + return map[string]interface{}{"inboxes": inboxPayload, "labels": labelPayload, "matrix": matrix}, nil +} + +func (s *AnalyticsService) firstResponseTimeDistribution(ctx context.Context, accountID uint, since, until time.Time) (map[string]map[string]int64, error) { + db, err := s.analyticsDB() + if err != nil { + return nil, err + } + var events []model.ReportingEvent + if err := db.WithContext(ctx).Where("account_id = ? AND name = ? AND created_at >= ? AND created_at < ?", accountID, model.MetricNameFirstResponse, since, until).Find(&events).Error; err != nil { + return nil, err + } + inboxIDs := make([]uint, 0, len(events)) + for _, event := range events { + if event.InboxID != nil { + inboxIDs = append(inboxIDs, *event.InboxID) + } + } + var inboxes []model.Inbox + if len(inboxIDs) > 0 { + if err := db.WithContext(ctx).Where("account_id = ? AND id IN ?", accountID, inboxIDs).Find(&inboxes).Error; err != nil { + return nil, err + } + } + channelTypes := map[uint]string{} + for _, inbox := range inboxes { + channelTypes[inbox.ID] = inbox.ChannelType + } + result := map[string]map[string]int64{} + for _, event := range events { + if event.InboxID == nil { + continue + } + channelType := channelTypes[*event.InboxID] + if channelType == "" { + continue + } + if result[channelType] == nil { + result[channelType] = map[string]int64{"0-1h": 0, "1-4h": 0, "4-8h": 0, "8-24h": 0, "24h+": 0} + } + switch { + case event.Value < 3600: + result[channelType]["0-1h"]++ + case event.Value < 14400: + result[channelType]["1-4h"]++ + case event.Value < 28800: + result[channelType]["4-8h"]++ + case event.Value < 86400: + result[channelType]["8-24h"]++ + default: + result[channelType]["24h+"]++ + } + } + return result, nil +} + +func (s *AnalyticsService) outgoingMessagesCount(ctx context.Context, accountID uint, since, until time.Time, groupBy string) ([]map[string]interface{}, error) { + if groupBy == "" { + groupBy = "agent" + } + db, err := s.analyticsDB() + if err != nil { + return nil, err + } + switch groupBy { + case "agent": + return s.outgoingMessagesByAgent(ctx, db, accountID, since, until) + case "team": + return s.outgoingMessagesByConversationField(ctx, db, accountID, since, until, "team_id", "teams") + case "inbox": + return s.outgoingMessagesByInbox(ctx, db, accountID, since, until) + case "label": + return s.outgoingMessagesByLabel(ctx, db, accountID, since, until) + default: + return nil, errors.New("invalid group_by") + } +} + +func (s *AnalyticsService) averageEventValue(ctx context.Context, accountID uint, names []string, since, until time.Time) (float64, error) { + db, err := s.analyticsDB() + if err != nil { + return 0, err + } + var row struct { + Value float64 + Count int64 + } + err = db.WithContext(ctx).Model(&model.ReportingEvent{}). + Select("COALESCE(SUM(value), 0) AS value, COUNT(*) AS count"). + Where("account_id = ? AND name IN ? AND created_at >= ? AND created_at < ?", accountID, names, since, until). + Scan(&row).Error + if err != nil || row.Count == 0 { + return 0, err + } + return row.Value / float64(row.Count), nil +} + +type outgoingCountRow struct { + ID uint + Name string + Count int64 +} + +func (s *AnalyticsService) outgoingMessagesByAgent(ctx context.Context, db *gorm.DB, accountID uint, since, until time.Time) ([]map[string]interface{}, error) { + var rows []outgoingCountRow + err := db.WithContext(ctx).Table("messages"). + Select("messages.sender_id AS id, users.name AS name, COUNT(*) AS count"). + Joins("LEFT JOIN users ON users.id = messages.sender_id"). + Where("messages.account_id = ? AND messages.message_type = ? AND messages.sender_type = ? AND messages.sender_id IS NOT NULL AND messages.created_at >= ? AND messages.created_at < ?", accountID, string(model.MessageTypeOutgoing), "User", since, until). + Group("messages.sender_id, users.name").Scan(&rows).Error + return outgoingRows(rows, "agent"), err +} + +func (s *AnalyticsService) outgoingMessagesByConversationField(ctx context.Context, db *gorm.DB, accountID uint, since, until time.Time, field, table string) ([]map[string]interface{}, error) { + var rows []outgoingCountRow + err := db.WithContext(ctx).Table("messages"). + Select("conversations."+field+" AS id, "+table+".name AS name, COUNT(*) AS count"). + Joins("INNER JOIN conversations ON conversations.id = messages.conversation_id"). + Joins("LEFT JOIN "+table+" ON "+table+".id = conversations."+field). + Where("messages.account_id = ? AND messages.message_type = ? AND conversations."+field+" IS NOT NULL AND messages.created_at >= ? AND messages.created_at < ?", accountID, string(model.MessageTypeOutgoing), since, until). + Group("conversations." + field + ", " + table + ".name").Scan(&rows).Error + return outgoingRows(rows, strings.TrimSuffix(field, "_id")), err +} + +func (s *AnalyticsService) outgoingMessagesByInbox(ctx context.Context, db *gorm.DB, accountID uint, since, until time.Time) ([]map[string]interface{}, error) { + var rows []outgoingCountRow + err := db.WithContext(ctx).Table("messages"). + Select("messages.inbox_id AS id, inboxes.name AS name, COUNT(*) AS count"). + Joins("LEFT JOIN inboxes ON inboxes.id = messages.inbox_id"). + Where("messages.account_id = ? AND messages.message_type = ? AND messages.created_at >= ? AND messages.created_at < ?", accountID, string(model.MessageTypeOutgoing), since, until). + Group("messages.inbox_id, inboxes.name").Scan(&rows).Error + return outgoingRows(rows, "inbox"), err +} + +func (s *AnalyticsService) outgoingMessagesByLabel(ctx context.Context, db *gorm.DB, accountID uint, since, until time.Time) ([]map[string]interface{}, error) { + var rows []outgoingCountRow + err := db.WithContext(ctx).Table("messages"). + Select("tags.id AS id, 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 +} + +func outgoingRows(rows []outgoingCountRow, _ string) []map[string]interface{} { + result := make([]map[string]interface{}, 0, len(rows)) + for _, row := range rows { + result = append(result, map[string]interface{}{"id": row.ID, "name": row.Name, "outgoing_messages_count": row.Count}) + } + return result +} diff --git a/internal/service/analytics_service.go b/internal/service/analytics_service.go index 2b14b272..888c07a3 100644 --- a/internal/service/analytics_service.go +++ b/internal/service/analytics_service.go @@ -4,11 +4,10 @@ import ( "context" "time" - "github.com/gin-gonic/gin" - "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" applogger "github.com/gochat/gochat/pkg/logger" + "gorm.io/gorm" ) // AnalyticsService implements business logic for reporting/analytics. @@ -16,15 +15,24 @@ import ( type AnalyticsService struct { eventRepo *repository.ReportingEventRepo rollupRepo *repository.ReportingEventsRollupRepo + db *gorm.DB } func NewAnalyticsService( eventRepo *repository.ReportingEventRepo, rollupRepo *repository.ReportingEventsRollupRepo, ) *AnalyticsService { + var db *gorm.DB + if rollupRepo != nil { + db = rollupRepo.DB() + } + if db == nil && eventRepo != nil { + db = eventRepo.DB() + } return &AnalyticsService{ eventRepo: eventRepo, rollupRepo: rollupRepo, + db: db, } } @@ -32,9 +40,9 @@ func NewAnalyticsService( // MetricSummary holds aggregated metric data. type MetricSummary struct { - Metric string `json:"metric"` - Count int64 `json:"count"` - AverageValue float64 `json:"average_value"` + Metric string `json:"metric"` + Count int64 `json:"count"` + AverageValue float64 `json:"average_value"` AverageBusinessHours float64 `json:"average_business_hours"` } @@ -52,8 +60,8 @@ type DimensionMetrics struct { // ConversationTrafficPoint holds a single time-series point. type ConversationTrafficPoint struct { - Date time.Time `json:"date"` - Count int64 `json:"count"` + Date time.Time `json:"date"` + Count int64 `json:"count"` } // ConversationMetrics holds real-time conversation counts. @@ -92,9 +100,9 @@ func (s *AnalyticsService) GetSummary(ctx context.Context, accountID uint, since m.AverageBusinessHours += r.SumValueBusinessHours } else { metricMap[key] = &MetricSummary{ - Metric: key, - Count: r.Count, - AverageValue: r.SumValue, + Metric: key, + Count: r.Count, + AverageValue: r.SumValue, AverageBusinessHours: r.SumValueBusinessHours, } } @@ -146,9 +154,9 @@ func (s *AnalyticsService) getDimensionMetrics(ctx context.Context, accountID ui for _, r := range rollups { if dm, ok := grouped[r.DimensionID]; ok { ms := MetricSummary{ - Metric: string(r.Metric), - Count: r.Count, - AverageValue: r.SumValue, + Metric: string(r.Metric), + Count: r.Count, + AverageValue: r.SumValue, AverageBusinessHours: r.SumValueBusinessHours, } if r.Count > 0 { @@ -158,9 +166,9 @@ func (s *AnalyticsService) getDimensionMetrics(ctx context.Context, accountID ui dm.Metrics = append(dm.Metrics, ms) } else { ms := MetricSummary{ - Metric: string(r.Metric), - Count: r.Count, - AverageValue: r.SumValue, + Metric: string(r.Metric), + Count: r.Count, + AverageValue: r.SumValue, AverageBusinessHours: r.SumValueBusinessHours, } if r.Count > 0 { @@ -204,22 +212,17 @@ func (s *AnalyticsService) GetConversationTraffic(ctx context.Context, accountID // GetConversationMetrics returns real-time conversation counts. // Reference: Chatwoot live_reports#conversation_metrics — open, unattended, unassigned, pending func (s *AnalyticsService) GetConversationMetrics(ctx context.Context, accountID uint) (*ConversationMetrics, error) { - // TODO: implement real-time open/unattended/unassigned/pending counts from conversations table - // Currently returns placeholder data until conversation queries are implemented - return &ConversationMetrics{ - OpenCount: 0, - UnattendedCount: 0, - UnassignedCount: 0, - PendingCount: 0, - }, nil + return s.GetConversationMetricsForTeam(ctx, accountID, 0) +} + +func (s *AnalyticsService) GetConversationMetricsForTeam(ctx context.Context, accountID uint, teamID uint) (*ConversationMetrics, error) { + return s.liveConversationMetrics(ctx, accountID, teamID) } // GetGroupedConversationMetrics returns conversation metrics grouped by team_id or assignee_id. // Reference: Chatwoot live_reports#grouped_conversation_metrics -func (s *AnalyticsService) GetGroupedConversationMetrics(ctx context.Context, accountID uint, groupBy string) ([]GroupedConversationMetric, error) { - // TODO: implement grouped conversation metrics from conversations table - // Currently returns empty until group-by queries are implemented - return []GroupedConversationMetric{}, nil +func (s *AnalyticsService) GetGroupedConversationMetrics(ctx context.Context, accountID uint, groupBy string) ([]map[string]interface{}, error) { + return s.groupedLiveConversationMetrics(ctx, accountID, groupBy) } // RecordEvent records a new reporting event. @@ -338,59 +341,66 @@ func (s *AnalyticsService) groupEventsByDimension(events []model.ReportingEvent, } return grouped } + // GetBotSummary returns bot-level summary metrics. // Reference: Chatwoot reports#bot_summary func (s *AnalyticsService) GetBotSummary(ctx context.Context, accountID uint, since, until time.Time) (*BotSummaryResponse, error) { - // TODO: implement bot summary using V2::Reports::BotSummaryBuilder logic - return &BotSummaryResponse{}, nil + current, err := s.botSummaryCounts(ctx, accountID, since, until) + if err != nil { + return nil, err + } + previousSince := since.Add(-(until.Sub(since))) + previous, err := s.botSummaryCounts(ctx, accountID, previousSince, since) + if err != nil { + return nil, err + } + current.Previous = previous + return current, nil } // BotSummaryResponse holds bot summary metrics. type BotSummaryResponse struct { - BotResolutions int64 `json:"bot_resolutions"` - BotHandoffs int64 `json:"bot_handoffs"` - SelfService int64 `json:"self_service"` - HumanService int64 `json:"human_service"` + BotResolutionsCount int64 `json:"bot_resolutions_count"` + BotHandoffsCount int64 `json:"bot_handoffs_count"` + Previous *BotSummaryResponse `json:"previous,omitempty"` } // GetConversationsByType returns conversation metrics filtered by report type. // Reference: Chatwoot reports#conversations — type param is required func (s *AnalyticsService) GetConversationsByType(ctx context.Context, accountID uint, reportType string, since, until time.Time) (interface{}, error) { - // TODO: implement conversation metrics by type using V2::Reports::Conversations::ConversationMetricsService - return gin.H{}, nil + return s.conversationMetricsByType(ctx, accountID, reportType) } // GetConversationsSummary returns conversations summary report. // Reference: Chatwoot reports#conversations_summary func (s *AnalyticsService) GetConversationsSummary(ctx context.Context, accountID uint, since, until time.Time) (interface{}, error) { - // TODO: implement conversations summary using V2::Reports::Conversations::SummaryBuilder - return gin.H{}, nil + return s.conversationSummary(ctx, accountID, since, until) } // GetBotMetrics returns bot metrics. // Reference: Chatwoot reports#bot_metrics — V2::Reports::BotMetricsBuilder func (s *AnalyticsService) GetBotMetrics(ctx context.Context, accountID uint, since, until time.Time) (interface{}, error) { - // TODO: implement bot metrics - return gin.H{}, nil + return s.botMetrics(ctx, accountID, since, until) } // GetInboxLabelMatrix returns inbox-label matrix data. // Reference: Chatwoot reports#inbox_label_matrix — V2::Reports::InboxLabelMatrixBuilder func (s *AnalyticsService) GetInboxLabelMatrix(ctx context.Context, accountID uint) (interface{}, error) { - // TODO: implement inbox label matrix - return gin.H{}, nil + return s.inboxLabelMatrix(ctx, accountID) } // GetFirstResponseTimeDistribution returns first response time distribution. // Reference: Chatwoot reports#first_response_time_distribution func (s *AnalyticsService) GetFirstResponseTimeDistribution(ctx context.Context, accountID uint, since, until time.Time) (interface{}, error) { - // TODO: implement FRT distribution - return gin.H{}, nil + return s.firstResponseTimeDistribution(ctx, accountID, since, until) } // GetOutgoingMessagesCount returns outgoing message count metrics. // Reference: Chatwoot reports#outgoing_messages_count func (s *AnalyticsService) GetOutgoingMessagesCount(ctx context.Context, accountID uint, since, until time.Time) (interface{}, error) { - // TODO: implement outgoing messages count - return gin.H{}, nil + return s.outgoingMessagesCount(ctx, accountID, since, until, "") +} + +func (s *AnalyticsService) GetOutgoingMessagesCountGrouped(ctx context.Context, accountID uint, since, until time.Time, groupBy string) (interface{}, error) { + return s.outgoingMessagesCount(ctx, accountID, since, until, groupBy) }